Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
722 lines
25 KiB
Go
722 lines
25 KiB
Go
// Permission model: group-resolved, layered document ACL (see
|
|
// migrations/008_permissions.sql). Access to a document is resolved through
|
|
// permission_groups (never directly per-user) over three layers, most
|
|
// specific winning:
|
|
//
|
|
// 1. document_grants — per-document, may 'deny' (removes a group entirely)
|
|
// 2. tag_grants — via the document's tags
|
|
// 3. document_type_grants — via the document's doc_type_id
|
|
//
|
|
// The resolved set is materialised into document_visibility by
|
|
// RecomputeVisibility, which must be re-run whenever any grant, a document's
|
|
// tags, or a document's doc_type changes. Roles remain the outer boundary:
|
|
// only role 'user' is ever filtered against document_visibility;
|
|
// domain_admin/superadmin bypass the ACL entirely (enforced in the handlers /
|
|
// ListDocuments caller).
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// ErrPermissionGroupNotFound is returned when a permission group lookup /
|
|
// mutation does not match a row owned by the caller's tenant.
|
|
var ErrPermissionGroupNotFound = errors.New("storage: permission group not found or not owned by tenant")
|
|
|
|
// ErrDuplicatePermissionGroup is returned on UNIQUE(tenant_id, name) violation.
|
|
var ErrDuplicatePermissionGroup = errors.New("storage: permission group with this name already exists for tenant")
|
|
|
|
// ErrGrantNotFound is returned when a grant delete matches no row.
|
|
var ErrGrantNotFound = errors.New("storage: grant not found")
|
|
|
|
// PermissionGroup is a named set of users, the unit ACL grants are attached to.
|
|
type PermissionGroup struct {
|
|
ID int64 `json:"id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// initPermissionsSchema creates the permission_groups /
|
|
// permission_group_members / *_grants / document_visibility tables. Idempotent,
|
|
// wired in from (*Store).New. Documented in migrations/008_permissions.sql.
|
|
func (s *Store) initPermissionsSchema(ctx context.Context) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
-- No FK on tenant_id / user_id columns: consistent with the rest of the
|
|
-- schema (documents/taxonomy use plain BIGINT tenant_id), and required
|
|
-- because the tenants/users tables are created by other stores that
|
|
-- initialise AFTER storage.New() (see cmd/archivdms/main.go ordering).
|
|
CREATE TABLE IF NOT EXISTS permission_groups (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tenant_id BIGINT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (tenant_id, name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS permission_group_members (
|
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL,
|
|
PRIMARY KEY (group_id, user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS document_type_grants (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tenant_id BIGINT NOT NULL,
|
|
doc_type_id BIGINT NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
|
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
|
access TEXT NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
|
|
UNIQUE (doc_type_id, group_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tag_grants (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tenant_id BIGINT NOT NULL,
|
|
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
|
access TEXT NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
|
|
UNIQUE (tag_id, group_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS document_grants (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tenant_id BIGINT NOT NULL,
|
|
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
|
access TEXT NOT NULL CHECK (access IN ('read','write','deny')),
|
|
granted_by BIGINT NOT NULL,
|
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (document_id, group_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS document_visibility (
|
|
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
|
access TEXT NOT NULL CHECK (access IN ('read','write')),
|
|
PRIMARY KEY (document_id, group_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_doc_visibility_group ON document_visibility(group_id, document_id);
|
|
CREATE INDEX IF NOT EXISTS idx_pgm_user ON permission_group_members(user_id, group_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_permission_groups_tenant ON permission_groups(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_document_type_grants_type ON document_type_grants(doc_type_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tag_grants_tag ON tag_grants(tag_id);
|
|
CREATE INDEX IF NOT EXISTS idx_document_grants_doc ON document_grants(document_id);
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: create permissions tables: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- permission groups ---
|
|
|
|
// CreatePermissionGroup inserts a new group for a tenant.
|
|
func (s *Store) CreatePermissionGroup(ctx context.Context, tenantID int64, name string) (*PermissionGroup, error) {
|
|
var g PermissionGroup
|
|
err := s.db.QueryRow(ctx, `
|
|
INSERT INTO permission_groups (tenant_id, name) VALUES ($1, $2)
|
|
RETURNING id, tenant_id, name, created_at
|
|
`, tenantID, name).Scan(&g.ID, &g.TenantID, &g.Name, &g.CreatedAt)
|
|
if err != nil {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return nil, ErrDuplicatePermissionGroup
|
|
}
|
|
return nil, fmt.Errorf("storage: create permission group: %w", err)
|
|
}
|
|
return &g, nil
|
|
}
|
|
|
|
// ListPermissionGroups returns all groups for a tenant, name-sorted.
|
|
func (s *Store) ListPermissionGroups(ctx context.Context, tenantID int64) ([]PermissionGroup, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, tenant_id, name, created_at FROM permission_groups
|
|
WHERE tenant_id = $1 ORDER BY name ASC
|
|
`, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list permission groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]PermissionGroup, 0)
|
|
for rows.Next() {
|
|
var g PermissionGroup
|
|
if err := rows.Scan(&g.ID, &g.TenantID, &g.Name, &g.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("storage: scan permission group: %w", err)
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// permissionGroupExists verifies the group belongs to the tenant.
|
|
func (s *Store) permissionGroupExists(ctx context.Context, groupID, tenantID int64) (bool, error) {
|
|
var exists bool
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM permission_groups WHERE id = $1 AND tenant_id = $2)
|
|
`, groupID, tenantID).Scan(&exists)
|
|
if err != nil {
|
|
return false, fmt.Errorf("storage: check permission group: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// DeletePermissionGroup removes a group (cascades to members/grants/visibility),
|
|
// scoped to tenant ownership.
|
|
func (s *Store) DeletePermissionGroup(ctx context.Context, groupID, tenantID int64) error {
|
|
tag, err := s.db.Exec(ctx, `DELETE FROM permission_groups WHERE id = $1 AND tenant_id = $2`, groupID, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete permission group: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- group membership ---
|
|
|
|
// AddGroupMember adds a user to a group, verifying both belong to the tenant.
|
|
// Idempotent (ON CONFLICT DO NOTHING).
|
|
func (s *Store) AddGroupMember(ctx context.Context, groupID, userID, tenantID int64) error {
|
|
ok, err := s.permissionGroupExists(ctx, groupID, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
var userOK bool
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND tenant_id = $2)
|
|
`, userID, tenantID).Scan(&userOK); err != nil {
|
|
return fmt.Errorf("storage: check user tenant: %w", err)
|
|
}
|
|
if !userOK {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
_, err = s.db.Exec(ctx, `
|
|
INSERT INTO permission_group_members (group_id, user_id) VALUES ($1, $2)
|
|
ON CONFLICT (group_id, user_id) DO NOTHING
|
|
`, groupID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: add group member: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveGroupMember removes a user from a group, scoped to tenant ownership of
|
|
// the group.
|
|
func (s *Store) RemoveGroupMember(ctx context.Context, groupID, userID, tenantID int64) error {
|
|
ok, err := s.permissionGroupExists(ctx, groupID, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
_, err = s.db.Exec(ctx, `DELETE FROM permission_group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: remove group member: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListGroupMembers returns the user IDs in a group, scoped to tenant.
|
|
func (s *Store) ListGroupMembers(ctx context.Context, groupID, tenantID int64) ([]int64, error) {
|
|
ok, err := s.permissionGroupExists(ctx, groupID, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, ErrPermissionGroupNotFound
|
|
}
|
|
rows, err := s.db.Query(ctx, `SELECT user_id FROM permission_group_members WHERE group_id = $1 ORDER BY user_id`, groupID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list group members: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []int64
|
|
for rows.Next() {
|
|
var uid int64
|
|
if err := rows.Scan(&uid); err != nil {
|
|
return nil, fmt.Errorf("storage: scan group member: %w", err)
|
|
}
|
|
out = append(out, uid)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GroupMember is a user enriched from the users table, as returned by
|
|
// ListGroupMembersDetailed for the group-administration UI.
|
|
type GroupMember struct {
|
|
UserID int64 `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// ListGroupMembersDetailed returns the members of a group joined against the
|
|
// users table (user_id, username, email), scoped to tenant ownership of the
|
|
// group. Returns an empty slice (not nil) for an empty group.
|
|
func (s *Store) ListGroupMembersDetailed(ctx context.Context, groupID, tenantID int64) ([]GroupMember, error) {
|
|
ok, err := s.permissionGroupExists(ctx, groupID, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, ErrPermissionGroupNotFound
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT u.id, u.username, u.email
|
|
FROM permission_group_members pgm
|
|
JOIN users u ON u.id = pgm.user_id
|
|
WHERE pgm.group_id = $1 AND u.tenant_id = $2
|
|
ORDER BY u.username ASC
|
|
`, groupID, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list group members detailed: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []GroupMember{}
|
|
for rows.Next() {
|
|
var m GroupMember
|
|
if err := rows.Scan(&m.UserID, &m.Username, &m.Email); err != nil {
|
|
return nil, fmt.Errorf("storage: scan group member detailed: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GrantInfo is a grant enriched with the group name, as returned by the
|
|
// grant-listing endpoints for the permission-administration UI.
|
|
type GrantInfo struct {
|
|
GroupID int64 `json:"group_id"`
|
|
GroupName string `json:"group_name"`
|
|
Access string `json:"access"`
|
|
}
|
|
|
|
// ListDocumentTypeGrants returns the grants on a document type (group_id,
|
|
// group_name, access), scoped to tenant. Verifies type ownership.
|
|
func (s *Store) ListDocumentTypeGrants(ctx context.Context, tenantID, docTypeID int64) ([]GrantInfo, error) {
|
|
var ok bool
|
|
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM document_types WHERE id = $1 AND tenant_id = $2)`, docTypeID, tenantID).Scan(&ok); err != nil {
|
|
return nil, fmt.Errorf("storage: check document type: %w", err)
|
|
}
|
|
if !ok {
|
|
return nil, ErrPermissionGroupNotFound
|
|
}
|
|
return s.listGrantInfos(ctx, `
|
|
SELECT dtg.group_id, pg.name, dtg.access
|
|
FROM document_type_grants dtg
|
|
JOIN permission_groups pg ON pg.id = dtg.group_id
|
|
WHERE dtg.tenant_id = $1 AND dtg.doc_type_id = $2
|
|
ORDER BY pg.name ASC
|
|
`, tenantID, docTypeID)
|
|
}
|
|
|
|
// ListTagGrants returns the grants on a tag (group_id, group_name, access),
|
|
// scoped to tenant. Verifies tag ownership.
|
|
func (s *Store) ListTagGrants(ctx context.Context, tenantID, tagID int64) ([]GrantInfo, error) {
|
|
var ok bool
|
|
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND tenant_id = $2)`, tagID, tenantID).Scan(&ok); err != nil {
|
|
return nil, fmt.Errorf("storage: check tag: %w", err)
|
|
}
|
|
if !ok {
|
|
return nil, ErrPermissionGroupNotFound
|
|
}
|
|
return s.listGrantInfos(ctx, `
|
|
SELECT tg.group_id, pg.name, tg.access
|
|
FROM tag_grants tg
|
|
JOIN permission_groups pg ON pg.id = tg.group_id
|
|
WHERE tg.tenant_id = $1 AND tg.tag_id = $2
|
|
ORDER BY pg.name ASC
|
|
`, tenantID, tagID)
|
|
}
|
|
|
|
// ListDocumentGrants returns the per-document grants (group_id, group_name,
|
|
// access — may be 'deny'), scoped to tenant. Verifies document ownership.
|
|
func (s *Store) ListDocumentGrants(ctx context.Context, tenantID, documentID int64) ([]GrantInfo, error) {
|
|
var ok bool
|
|
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM documents WHERE id = $1 AND tenant_id = $2)`, documentID, tenantID).Scan(&ok); err != nil {
|
|
return nil, fmt.Errorf("storage: check document: %w", err)
|
|
}
|
|
if !ok {
|
|
return nil, ErrPermissionGroupNotFound
|
|
}
|
|
return s.listGrantInfos(ctx, `
|
|
SELECT dg.group_id, pg.name, dg.access
|
|
FROM document_grants dg
|
|
JOIN permission_groups pg ON pg.id = dg.group_id
|
|
WHERE dg.tenant_id = $1 AND dg.document_id = $2
|
|
ORDER BY pg.name ASC
|
|
`, tenantID, documentID)
|
|
}
|
|
|
|
// listGrantInfos runs a (group_id, group_name, access) query and scans it into
|
|
// a non-nil GrantInfo slice.
|
|
func (s *Store) listGrantInfos(ctx context.Context, query string, args ...any) ([]GrantInfo, error) {
|
|
rows, err := s.db.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []GrantInfo{}
|
|
for rows.Next() {
|
|
var g GrantInfo
|
|
if err := rows.Scan(&g.GroupID, &g.GroupName, &g.Access); err != nil {
|
|
return nil, fmt.Errorf("storage: scan grant info: %w", err)
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListGroupIDsForUser returns the permission-group ids a user belongs to,
|
|
// scoped to the tenant (a group is only counted when it is owned by the
|
|
// tenant). Used by the search endpoint to build the ANY(acl_group_ids) filter
|
|
// for role 'user' (domain_admin/superadmin never call this — they bypass the
|
|
// ACL). Returns an empty slice (not nil) when the user is in no group, so the
|
|
// caller can distinguish "no ACL filter" (nil) from "sees nothing" (empty).
|
|
func (s *Store) ListGroupIDsForUser(ctx context.Context, userID, tenantID int64) ([]int64, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT pgm.group_id
|
|
FROM permission_group_members pgm
|
|
JOIN permission_groups pg ON pg.id = pgm.group_id
|
|
WHERE pgm.user_id = $1 AND pg.tenant_id = $2
|
|
ORDER BY pgm.group_id
|
|
`, userID, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list group ids for user: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []int64{}
|
|
for rows.Next() {
|
|
var gid int64
|
|
if err := rows.Scan(&gid); err != nil {
|
|
return nil, fmt.Errorf("storage: scan group id: %w", err)
|
|
}
|
|
out = append(out, gid)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// --- document-type grants ---
|
|
|
|
// SetDocumentTypeGrant upserts a document-type default grant and recomputes
|
|
// visibility for every document of that type. Verifies type + group tenant.
|
|
func (s *Store) SetDocumentTypeGrant(ctx context.Context, tenantID, docTypeID, groupID int64, access string) error {
|
|
if access != "read" && access != "write" {
|
|
return fmt.Errorf("storage: invalid access %q", access)
|
|
}
|
|
if err := s.checkTypeAndGroup(ctx, tenantID, docTypeID, groupID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.Exec(ctx, `
|
|
INSERT INTO document_type_grants (tenant_id, doc_type_id, group_id, access)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (doc_type_id, group_id) DO UPDATE SET access = EXCLUDED.access
|
|
`, tenantID, docTypeID, groupID, access)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: set document type grant: %w", err)
|
|
}
|
|
return s.recomputeVisibilityForDocType(ctx, tenantID, docTypeID)
|
|
}
|
|
|
|
// DeleteDocumentTypeGrant removes a document-type grant and recomputes
|
|
// visibility for that type's documents.
|
|
func (s *Store) DeleteDocumentTypeGrant(ctx context.Context, tenantID, docTypeID, groupID int64) error {
|
|
tag, err := s.db.Exec(ctx, `
|
|
DELETE FROM document_type_grants WHERE tenant_id = $1 AND doc_type_id = $2 AND group_id = $3
|
|
`, tenantID, docTypeID, groupID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete document type grant: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrGrantNotFound
|
|
}
|
|
return s.recomputeVisibilityForDocType(ctx, tenantID, docTypeID)
|
|
}
|
|
|
|
// --- tag grants ---
|
|
|
|
// SetTagGrant upserts a tag grant and recomputes visibility for every document
|
|
// carrying that tag. Verifies tag + group tenant.
|
|
func (s *Store) SetTagGrant(ctx context.Context, tenantID, tagID, groupID int64, access string) error {
|
|
if access != "read" && access != "write" {
|
|
return fmt.Errorf("storage: invalid access %q", access)
|
|
}
|
|
if err := s.checkTagAndGroup(ctx, tenantID, tagID, groupID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.Exec(ctx, `
|
|
INSERT INTO tag_grants (tenant_id, tag_id, group_id, access)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (tag_id, group_id) DO UPDATE SET access = EXCLUDED.access
|
|
`, tenantID, tagID, groupID, access)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: set tag grant: %w", err)
|
|
}
|
|
return s.recomputeVisibilityForTag(ctx, tagID)
|
|
}
|
|
|
|
// DeleteTagGrant removes a tag grant and recomputes visibility for that tag's
|
|
// documents.
|
|
func (s *Store) DeleteTagGrant(ctx context.Context, tenantID, tagID, groupID int64) error {
|
|
tag, err := s.db.Exec(ctx, `
|
|
DELETE FROM tag_grants WHERE tenant_id = $1 AND tag_id = $2 AND group_id = $3
|
|
`, tenantID, tagID, groupID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete tag grant: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrGrantNotFound
|
|
}
|
|
return s.recomputeVisibilityForTag(ctx, tagID)
|
|
}
|
|
|
|
// --- document grants ---
|
|
|
|
// SetDocumentGrant upserts a per-document grant ('read'/'write'/'deny') and
|
|
// recomputes visibility for that document. Verifies document + group tenant.
|
|
func (s *Store) SetDocumentGrant(ctx context.Context, tenantID, documentID, groupID, grantedBy int64, access string) error {
|
|
if access != "read" && access != "write" && access != "deny" {
|
|
return fmt.Errorf("storage: invalid access %q", access)
|
|
}
|
|
if err := s.checkDocAndGroup(ctx, tenantID, documentID, groupID); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.db.Exec(ctx, `
|
|
INSERT INTO document_grants (tenant_id, document_id, group_id, access, granted_by)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (document_id, group_id) DO UPDATE SET access = EXCLUDED.access, granted_by = EXCLUDED.granted_by, granted_at = now()
|
|
`, tenantID, documentID, groupID, access, grantedBy)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: set document grant: %w", err)
|
|
}
|
|
return s.RecomputeVisibility(ctx, documentID)
|
|
}
|
|
|
|
// DeleteDocumentGrant removes a per-document grant and recomputes visibility.
|
|
func (s *Store) DeleteDocumentGrant(ctx context.Context, tenantID, documentID, groupID int64) error {
|
|
tag, err := s.db.Exec(ctx, `
|
|
DELETE FROM document_grants WHERE tenant_id = $1 AND document_id = $2 AND group_id = $3
|
|
`, tenantID, documentID, groupID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete document grant: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrGrantNotFound
|
|
}
|
|
return s.RecomputeVisibility(ctx, documentID)
|
|
}
|
|
|
|
// --- tenant/ownership checks ---
|
|
|
|
func (s *Store) checkTypeAndGroup(ctx context.Context, tenantID, docTypeID, groupID int64) error {
|
|
var ok bool
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM document_types WHERE id = $1 AND tenant_id = $2)
|
|
AND EXISTS(SELECT 1 FROM permission_groups WHERE id = $3 AND tenant_id = $2)
|
|
`, docTypeID, tenantID, groupID).Scan(&ok); err != nil {
|
|
return fmt.Errorf("storage: check type/group: %w", err)
|
|
}
|
|
if !ok {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) checkTagAndGroup(ctx context.Context, tenantID, tagID, groupID int64) error {
|
|
var ok bool
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND tenant_id = $2)
|
|
AND EXISTS(SELECT 1 FROM permission_groups WHERE id = $3 AND tenant_id = $2)
|
|
`, tagID, tenantID, groupID).Scan(&ok); err != nil {
|
|
return fmt.Errorf("storage: check tag/group: %w", err)
|
|
}
|
|
if !ok {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) checkDocAndGroup(ctx context.Context, tenantID, documentID, groupID int64) error {
|
|
var ok bool
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM documents WHERE id = $1 AND tenant_id = $2)
|
|
AND EXISTS(SELECT 1 FROM permission_groups WHERE id = $3 AND tenant_id = $2)
|
|
`, documentID, tenantID, groupID).Scan(&ok); err != nil {
|
|
return fmt.Errorf("storage: check doc/group: %w", err)
|
|
}
|
|
if !ok {
|
|
return ErrPermissionGroupNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- visibility recomputation ---
|
|
|
|
// recomputeVisibilityForDocType recomputes visibility for all documents of a
|
|
// given document type in a tenant.
|
|
func (s *Store) recomputeVisibilityForDocType(ctx context.Context, tenantID, docTypeID int64) error {
|
|
ids, err := s.collectDocIDs(ctx, `SELECT id FROM documents WHERE tenant_id = $1 AND doc_type_id = $2`, tenantID, docTypeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.recomputeMany(ctx, ids)
|
|
}
|
|
|
|
// recomputeVisibilityForTag recomputes visibility for all documents carrying a
|
|
// given tag.
|
|
func (s *Store) recomputeVisibilityForTag(ctx context.Context, tagID int64) error {
|
|
ids, err := s.collectDocIDs(ctx, `SELECT document_id FROM document_tags WHERE tag_id = $1`, tagID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.recomputeMany(ctx, ids)
|
|
}
|
|
|
|
func (s *Store) collectDocIDs(ctx context.Context, query string, args ...any) ([]int64, error) {
|
|
rows, err := s.db.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: collect document ids: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var ids []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("storage: scan document id: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
func (s *Store) recomputeMany(ctx context.Context, ids []int64) error {
|
|
for _, id := range ids {
|
|
if err := s.RecomputeVisibility(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecomputeVisibility rebuilds document_visibility for a single document by
|
|
// resolving the three grant layers (document > tag > doc_type). A
|
|
// document_grant with access 'deny' removes that group from every lower layer.
|
|
// DELETE+INSERT run in one transaction so a document's visibility is never
|
|
// observed half-written.
|
|
func (s *Store) RecomputeVisibility(ctx context.Context, documentID int64) error {
|
|
// Layer 1: per-document grants (highest priority). 'deny' blocks a group.
|
|
docGrants, err := s.readGrants(ctx, `SELECT group_id, access FROM document_grants WHERE document_id = $1`, documentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
denied := make(map[int64]bool)
|
|
resolved := make(map[int64]string)
|
|
for _, g := range docGrants {
|
|
if g.access == "deny" {
|
|
denied[g.group] = true
|
|
continue
|
|
}
|
|
resolved[g.group] = g.access
|
|
}
|
|
|
|
// Layer 2: tag grants, via the document's tags.
|
|
tagGrants, err := s.readGrants(ctx, `
|
|
SELECT tg.group_id, tg.access
|
|
FROM tag_grants tg
|
|
JOIN document_tags dt ON dt.tag_id = tg.tag_id
|
|
WHERE dt.document_id = $1
|
|
`, documentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, g := range tagGrants {
|
|
applyLayer(resolved, denied, g.group, g.access)
|
|
}
|
|
|
|
// Layer 3: document-type default grants.
|
|
typeGrants, err := s.readGrants(ctx, `
|
|
SELECT dtg.group_id, dtg.access
|
|
FROM document_type_grants dtg
|
|
JOIN documents d ON d.doc_type_id = dtg.doc_type_id
|
|
WHERE d.id = $1
|
|
`, documentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, g := range typeGrants {
|
|
applyLayer(resolved, denied, g.group, g.access)
|
|
}
|
|
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: begin recompute visibility: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM document_visibility WHERE document_id = $1`, documentID); err != nil {
|
|
return fmt.Errorf("storage: clear document visibility: %w", err)
|
|
}
|
|
for group, access := range resolved {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO document_visibility (document_id, group_id, access) VALUES ($1, $2, $3)
|
|
`, documentID, group, access); err != nil {
|
|
return fmt.Errorf("storage: insert document visibility: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("storage: commit recompute visibility: %w", err)
|
|
}
|
|
// The materialised ACL (and, when reached via AttachTag/DetachTag/
|
|
// SetDocumentDocType, the tags/doc_type) just changed — re-sync the search
|
|
// index. Best-effort: never fails the caller.
|
|
s.SyncIndex(ctx, documentID)
|
|
return nil
|
|
}
|
|
|
|
// applyLayer adds a lower-priority grant only when the group is neither denied
|
|
// nor already resolved by a higher layer. Within a layer, 'write' beats 'read'.
|
|
func applyLayer(resolved map[int64]string, denied map[int64]bool, group int64, access string) {
|
|
if denied[group] {
|
|
return
|
|
}
|
|
if existing, ok := resolved[group]; ok {
|
|
if existing == "write" || access != "write" {
|
|
return
|
|
}
|
|
}
|
|
resolved[group] = access
|
|
}
|
|
|
|
type grantRow struct {
|
|
group int64
|
|
access string
|
|
}
|
|
|
|
func (s *Store) readGrants(ctx context.Context, query string, args ...any) ([]grantRow, error) {
|
|
rows, err := s.db.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: read grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []grantRow
|
|
for rows.Next() {
|
|
var g grantRow
|
|
if err := rows.Scan(&g.group, &g.access); err != nil {
|
|
return nil, fmt.Errorf("storage: scan grant: %w", err)
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out, rows.Err()
|
|
}
|