inou/lib/access.go

382 lines
9.8 KiB
Go

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 := dbQuery(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 := dbSave("access", grant)
if err == nil {
InvalidateCacheForAccessor(granteeID)
}
return err
}
func RevokeAccess(accessID string) error {
var grant Access
if err := dbLoad("access", accessID, &grant); err != nil {
return err
}
err := dbDelete("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
}