// 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() }