package lib import ( "fmt" "sync" "time" ) // ============================================================================ // RBAC Access Control // ============================================================================ // // Grants live at three levels: // 1. Root (entry_id = "") — applies to all data // 2. Category — grant on a category/category_root entry // 3. Entry-specific — grant on an individual entry (rare) // // Operations: r=read, w=write, d=delete, m=manage // // Resolved once per accessor+dossier (cached until permissions change): // rootOps — ops from root grant // categoryOps[cat] — ops from category-level grants // hasChildGrants[cat] — true if entry-specific grants exist in this category // // Access check (hot path, 99% of cases = zero DB lookups): // 1. categoryOps[cat] exists, no child grants → return it // 2. categoryOps[cat] exists, has child grants → check entry, fall back to category // 3. rootOps // ============================================================================ // AccessContext represents who is making the request type AccessContext struct { AccessorID string // dossier_id of the requester IsSystem bool // bypass RBAC (internal operations only) } // SystemContext is used for internal operations that bypass RBAC var SystemContext *AccessContext var ErrAccessDenied = fmt.Errorf("access denied") var ErrNoAccessor = fmt.Errorf("no accessor specified") // ============================================================================ // Permission Cache // ============================================================================ type resolvedGrants struct { rootOps string // ops for root grant (entry_id="") categoryOps map[int]string // category → ops hasChildGrants map[int]bool // category → has entry-specific grants? entryOps map[string]string // entry_id → ops (only for rare entry-level grants) } type permissionCache struct { mu sync.RWMutex cache map[string]map[string]*resolvedGrants // [accessor][dossier] } var permCache = &permissionCache{ cache: make(map[string]map[string]*resolvedGrants), } func (c *permissionCache) get(accessorID, dossierID string) *resolvedGrants { c.mu.RLock() defer c.mu.RUnlock() if c.cache[accessorID] == nil { return nil } return c.cache[accessorID][dossierID] } func (c *permissionCache) set(accessorID, dossierID string, rg *resolvedGrants) { c.mu.Lock() defer c.mu.Unlock() if c.cache[accessorID] == nil { c.cache[accessorID] = make(map[string]*resolvedGrants) } c.cache[accessorID][dossierID] = rg } func InvalidateCacheForAccessor(accessorID string) { permCache.mu.Lock() defer permCache.mu.Unlock() delete(permCache.cache, accessorID) } func InvalidateCacheForDossier(dossierID string) { permCache.mu.Lock() defer permCache.mu.Unlock() for accessorID := range permCache.cache { delete(permCache.cache[accessorID], dossierID) } } func InvalidateCacheAll() { permCache.mu.Lock() defer permCache.mu.Unlock() permCache.cache = make(map[string]map[string]*resolvedGrants) } // ============================================================================ // Core Permission Check // ============================================================================ // checkAccess checks if accessor can perform op on dossier/entry. // category: entry's category if known (0 = look up from entryID if needed) func checkAccess(accessorID, dossierID, entryID string, category int, op rune) error { if accessorID == SystemAccessorID { return nil } if accessorID == dossierID { return nil } if hasOp(getEffectiveOps(accessorID, dossierID, entryID, category), op) { return nil } return ErrAccessDenied } // CheckAccess is the exported version (category unknown). func CheckAccess(accessorID, dossierID, entryID string, op rune) error { return checkAccess(accessorID, dossierID, entryID, 0, op) } // getEffectiveOps returns ops for accessor on dossier/entry. // category >0 avoids a DB lookup to determine the entry's category. func getEffectiveOps(accessorID, dossierID, entryID string, category int) string { rg := resolveGrants(accessorID, dossierID) if entryID != "" { // Determine category cat := category if cat == 0 { if e, err := entryGetRaw(entryID); err == nil && e != nil { cat = e.Category } } if cat > 0 { catOps, hasCat := rg.categoryOps[cat] // 99% path: category grant, no child grants → done if hasCat && !rg.hasChildGrants[cat] { return catOps } // Rare: entry-specific grants exist in this category if rg.hasChildGrants[cat] { if ops, ok := rg.entryOps[entryID]; ok { return ops } // Fall back to category grant if hasCat { return catOps } } } } return rg.rootOps } // resolveGrants loads grants for accessor+dossier, resolves each into // root/category/entry buckets. Cached until permissions change. func resolveGrants(accessorID, dossierID string) *resolvedGrants { if rg := permCache.get(accessorID, dossierID); rg != nil { return rg } rg := &resolvedGrants{ categoryOps: make(map[int]string), hasChildGrants: make(map[int]bool), entryOps: make(map[string]string), } grants, err := accessGrantListRaw(&PermissionFilter{ DossierID: dossierID, GranteeID: accessorID, }) if err != nil || len(grants) == 0 { permCache.set(accessorID, dossierID, rg) return rg } for _, g := range grants { if g.EntryID == "" { rg.rootOps = mergeOps(rg.rootOps, g.Ops) continue } entry, err := entryGetRaw(g.EntryID) if err != nil || entry == nil { continue } if entry.Type == "category" || entry.Type == "category_root" { rg.categoryOps[entry.Category] = mergeOps(rg.categoryOps[entry.Category], g.Ops) } else { rg.entryOps[g.EntryID] = mergeOps(rg.entryOps[g.EntryID], g.Ops) rg.hasChildGrants[entry.Category] = true } } permCache.set(accessorID, dossierID, rg) return rg } // ============================================================================ // Helpers // ============================================================================ func mergeOps(a, b string) string { ops := make(map[rune]bool) for _, c := range a { ops[c] = true } for _, c := range b { ops[c] = true } result := "" for _, c := range "rwdm" { if ops[c] { result += string(c) } } return result } func hasOp(ops string, op rune) bool { for _, c := range ops { if c == op { return true } } return false } func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) { q := "SELECT * FROM access WHERE 1=1" args := []any{} if f != nil { if f.DossierID != "" { q += " AND dossier_id = ?" args = append(args, f.DossierID) } if f.GranteeID != "" { q += " AND grantee_id = ?" args = append(args, f.GranteeID) } if f.EntryID != "" { q += " AND entry_id = ?" args = append(args, f.EntryID) } if f.Role != "" { q += " AND role = ?" args = append(args, CryptoEncrypt(f.Role)) } } q += " ORDER BY created_at DESC" var result []*Access err := Query(q, args, &result) return result, err } // ============================================================================ // Utility Functions // ============================================================================ // EnsureCategoryRoot finds or creates the root entry for a category in a dossier. // This entry serves as parent for all entries of that category and as the // target for RBAC category-level grants. func EnsureCategoryRoot(dossierID string, category int) (string, error) { // Look for existing category_root entry entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{ DossierID: dossierID, Type: "category_root", Limit: 1, }) if err == nil && len(entries) > 0 { return entries[0].EntryID, nil } // Create category root entry entry := &Entry{ DossierID: dossierID, Category: category, Type: "category_root", Value: CategoryName(category), } if err := EntryWrite(nil, entry); err != nil { return "", err } return entry.EntryID, nil } func CanAccessDossier(accessorID, dossierID string) bool { return CheckAccess(accessorID, dossierID, "", 'r') == nil } func CanManageDossier(accessorID, dossierID string) bool { return CheckAccess(accessorID, dossierID, "", 'm') == nil } func GrantAccess(dossierID, granteeID, entryID, ops string) error { grant := &Access{ DossierID: dossierID, GranteeID: granteeID, EntryID: entryID, Ops: ops, CreatedAt: time.Now().Unix(), } err := Save("access", grant) if err == nil { InvalidateCacheForAccessor(granteeID) } return err } func RevokeAccess(accessID string) error { var grant Access if err := Load("access", accessID, &grant); err != nil { return err } err := Delete("access", "access_id", accessID) if err == nil { InvalidateCacheForAccessor(grant.GranteeID) } return err } func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string { if ctx == nil || ctx.AccessorID == "" { if ctx != nil && ctx.IsSystem { return "rwdm" } return "" } if ctx.AccessorID == dossierID { return "rwdm" } return getEffectiveOps(ctx.AccessorID, dossierID, entryID, 0) } func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) { if ctx == nil || ctx.AccessorID == "" { if ctx != nil && ctx.IsSystem { return DossierList(nil, nil) } return nil, ErrNoAccessor } own, err := dossierGetRaw(ctx.AccessorID) if err != nil { return nil, ErrAccessDenied } result := []*Dossier{own} grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID}) if err != nil { return result, nil } seen := map[string]bool{ctx.AccessorID: true} for _, g := range grants { if g.DossierID == "" || seen[g.DossierID] { continue } if g.CanRead() { seen[g.DossierID] = true if d, err := dossierGetRaw(g.DossierID); err == nil { result = append(result, d) } } } return result, nil }