382 lines
9.8 KiB
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
|
|
}
|