inou/lib/access.go

443 lines
12 KiB
Go

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.
//
// Algorithm:
// 1. ctx == nil → allow (backward compatibility, internal operations)
// 2. ctx.IsSystem → allow
// 3. Accessor == dossier owner → allow (full access to own data)
// 4. Check grants for accessor on this dossier:
// a. Entry-specific grant (entry_id matches)
// b. Walk up parent_id chain checking each level
// c. Root grant (entry_id = "")
// 5. No matching grant → deny
func checkAccess(ctx *AccessContext, dossierID, entryID string, op rune) error {
// 1. nil context allows (for internal operations that pass nil)
if ctx == nil {
return nil
}
// 2. System context bypasses all checks
if ctx.IsSystem {
return nil
}
// Must have accessor for non-system context
if ctx.AccessorID == "" {
return ErrNoAccessor
}
// 3. Owner has full access to own data
if ctx.AccessorID == dossierID {
return nil
}
// 4. Check grants
ops := getEffectiveOps(ctx.AccessorID, dossierID, entryID)
if hasOp(ops, op) {
return nil
}
// 5. Deny
return ErrAccessDenied
}
// CheckAccess is the exported version for use by API/Portal code.
// Same algorithm as checkAccess but requires non-nil context.
func CheckAccess(ctx *AccessContext, dossierID, entryID string, op rune) error {
if ctx == nil {
return ErrNoAccessor
}
return checkAccess(ctx, 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
entries, err := EntryList(SystemContext, "", 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(ctx *AccessContext, dossierID string) bool {
return CheckAccess(ctx, dossierID, "", 'r') == nil
}
// CanManageDossier returns true if accessor can manage permissions for dossier
func CanManageDossier(ctx *AccessContext, dossierID string) bool {
return CheckAccess(ctx, 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
}