package lib import ( "fmt" "sync" "time" ) // ============================================================================ // RBAC Access Control - Entry-based permission system // ============================================================================ // // Three-level hierarchy: // 1. Root (entry_id = "") - "all" or "nothing" // 2. Categories - entries that are category roots (parent_id = "") // 3. Individual entries - access flows down via parent_id chain // // Operations: // r = read - view data // w = write - create/update data // d = delete - remove data // m = manage - grant/revoke access to others // "" = explicit denial (removes inherited access) // // Categories are just entries - no special handling. // Access to parent implies access to all children. // // ============================================================================ // 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{IsSystem: true} // ErrAccessDenied is returned when permission check fails var ErrAccessDenied = fmt.Errorf("access denied") // ErrNoAccessor is returned when AccessorID is empty and IsSystem is false var ErrNoAccessor = fmt.Errorf("no accessor specified") // ============================================================================ // Permission Cache // ============================================================================ type cacheEntry struct { ops string // "r", "rw", "rwd", "rwdm" expiresAt time.Time } type permissionCache struct { mu sync.RWMutex cache map[string]map[string]map[string]*cacheEntry // [accessor][dossier][entry_id] -> ops ttl time.Duration } var permCache = &permissionCache{ cache: make(map[string]map[string]map[string]*cacheEntry), ttl: time.Hour, } // get returns cached ops or empty string if not found/expired func (c *permissionCache) get(accessorID, dossierID, entryID string) string { c.mu.RLock() defer c.mu.RUnlock() if c.cache[accessorID] == nil { return "" } if c.cache[accessorID][dossierID] == nil { return "" } entry := c.cache[accessorID][dossierID][entryID] if entry == nil || time.Now().After(entry.expiresAt) { return "" } return entry.ops } // set stores ops in cache func (c *permissionCache) set(accessorID, dossierID, entryID, ops string) { c.mu.Lock() defer c.mu.Unlock() if c.cache[accessorID] == nil { c.cache[accessorID] = make(map[string]map[string]*cacheEntry) } if c.cache[accessorID][dossierID] == nil { c.cache[accessorID][dossierID] = make(map[string]*cacheEntry) } c.cache[accessorID][dossierID][entryID] = &cacheEntry{ ops: ops, expiresAt: time.Now().Add(c.ttl), } } // InvalidateCacheForAccessor clears all cached permissions for an accessor func InvalidateCacheForAccessor(accessorID string) { permCache.mu.Lock() defer permCache.mu.Unlock() delete(permCache.cache, accessorID) } // InvalidateCacheForDossier clears all cached permissions for a dossier func InvalidateCacheForDossier(dossierID string) { permCache.mu.Lock() defer permCache.mu.Unlock() for accessorID := range permCache.cache { delete(permCache.cache[accessorID], dossierID) } } // InvalidateCacheAll clears entire cache func InvalidateCacheAll() { permCache.mu.Lock() defer permCache.mu.Unlock() permCache.cache = make(map[string]map[string]map[string]*cacheEntry) } // ============================================================================ // Core Permission Check (used by v2.go functions) // ============================================================================ // checkAccess is the internal permission check called by v2.go data functions. // Returns nil if allowed, ErrAccessDenied if not. // // Parameters: // accessorID - who is asking (empty = system/internal) // dossierID - whose data // entryID - specific entry (empty = root level) // op - operation: 'r', 'w', 'd', 'm' // // Algorithm: // 1. Empty accessor → allow (system/internal operations) // 2. Accessor == owner → allow (full access to own data) // 3. Check grants (entry-specific → parent chain → root) // 4. No grant → deny func checkAccess(accessorID, dossierID, entryID string, op rune) error { // 1. Empty accessor = system/internal operation if accessorID == "" { return nil } // 2. Owner has full access to own data if accessorID == dossierID { return nil } // 3. Check grants ops := getEffectiveOps(accessorID, dossierID, entryID) if hasOp(ops, op) { return nil } // 4. No grant found - deny return ErrAccessDenied } // CheckAccess is the exported version for use by API/Portal code. func CheckAccess(accessorID, dossierID, entryID string, op rune) error { return checkAccess(accessorID, dossierID, entryID, op) } // getEffectiveOps returns the ops string for accessor on dossier/entry // Uses cache, falls back to database lookup func getEffectiveOps(accessorID, dossierID, entryID string) string { // Check cache first if ops := permCache.get(accessorID, dossierID, entryID); ops != "" { return ops } // Load grants from database (bypasses RBAC - internal function) grants, err := accessGrantListRaw(&PermissionFilter{ DossierID: dossierID, GranteeID: accessorID, }) if err != nil || len(grants) == 0 { // Cache negative result permCache.set(accessorID, dossierID, entryID, "") return "" } // Find most specific matching grant ops := findMatchingOps(grants, entryID) permCache.set(accessorID, dossierID, entryID, ops) return ops } // findMatchingOps finds the most specific grant that applies to entryID // Priority: entry-specific > parent chain > root // Categories are just entries - no special handling needed func findMatchingOps(grants []*Access, entryID string) string { // Build entry->ops map for quick lookup grantMap := make(map[string]string) // entry_id -> ops (empty key = root) for _, g := range grants { existing := grantMap[g.EntryID] // Merge ops (keep most permissive) grantMap[g.EntryID] = mergeOps(existing, g.Ops) } // 1. Check entry-specific grant (including category entries) if entryID != "" { if ops, ok := grantMap[entryID]; ok { return ops } // 2. Walk up parent chain (using raw function to avoid RBAC recursion) currentID := entryID for i := 0; i < 100; i++ { // max depth to prevent infinite loops entry, err := entryGetRaw(currentID) if err != nil || entry == nil { break } if entry.ParentID == "" { break } // Check parent for grant if ops, ok := grantMap[entry.ParentID]; ok { return ops } currentID = entry.ParentID } } // 3. Check root grant (entry_id = "" means "all") if ops, ok := grantMap[""]; ok { return ops } // 4. No grant found return "" } // mergeOps combines two ops strings, keeping the most permissive 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 } // hasOp checks if ops string contains the requested operation func hasOp(ops string, op rune) bool { for _, c := range ops { if c == op { return true } } return false } // accessGrantListRaw loads grants without RBAC check (for internal use by permission system) 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 // ============================================================================ // EnsureCategoryEntry creates a category entry if it doesn't exist // Returns the entry_id of the category entry func EnsureCategoryEntry(dossierID string, category int) (string, error) { // Check if category entry already exists (use empty string for system context) entries, err := EntryList("", "", category, &EntryFilter{ DossierID: dossierID, Type: "category", Limit: 1, }) if err != nil { return "", err } if len(entries) > 0 { return entries[0].EntryID, nil } // Create category entry entry := &Entry{ DossierID: dossierID, Category: category, Type: "category", Value: CategoryName(category), ParentID: "", // Categories are root-level } if err := EntryWrite(SystemContext, entry); err != nil { return "", err } return entry.EntryID, nil } // CanAccessDossier returns true if accessor can read dossier (for quick checks) func CanAccessDossier(accessorID, dossierID string) bool { return CheckAccess(accessorID, dossierID, "", 'r') == nil } // CanManageDossier returns true if accessor can manage permissions for dossier func CanManageDossier(accessorID, dossierID string) bool { return CheckAccess(accessorID, dossierID, "", 'm') == nil } // GrantAccess creates an access grant // If entryID is empty, grants root-level access // If entryID is a category, ensures category entry exists first 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 } // RevokeAccess removes an access grant func RevokeAccess(accessID string) error { // Get the grant to know which accessor to invalidate 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 } // GetAccessorOps returns the operations accessor can perform on dossier/entry // Returns empty string if no access func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string { if ctx == nil || ctx.AccessorID == "" { if ctx != nil && ctx.IsSystem { return "rwdm" } return "" } // Owner has full access if ctx.AccessorID == dossierID { return "rwdm" } return getEffectiveOps(ctx.AccessorID, dossierID, entryID) } // DossierListAccessible returns all dossiers accessible by ctx.AccessorID func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) { if ctx == nil || ctx.AccessorID == "" { if ctx != nil && ctx.IsSystem { // System context: return all return DossierList(nil, nil) } return nil, ErrNoAccessor } // Get accessor's own dossier own, err := dossierGetRaw(ctx.AccessorID) if err != nil { // Invalid accessor (doesn't exist) - treat as unauthorized return nil, ErrAccessDenied } result := []*Dossier{own} // Get all grants where accessor is grantee grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID}) if err != nil { return result, nil // Return just own dossier on error } // Collect unique dossier IDs with read permission 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 }