Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

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