462 lines
13 KiB
Go
462 lines
13 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
|
|
// Initialized in ConfigInit() with SystemAccessorID from config
|
|
var SystemContext *AccessContext
|
|
|
|
// 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. System accessor → allow (internal operations with audit trail)
|
|
// 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. System accessor = internal operation (explicit backdoor for audit)
|
|
if accessorID == SystemAccessorID {
|
|
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(SystemAccessorID, "", 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)
|
|
// Falls back to old dossier_access for backward compatibility
|
|
func CanAccessDossier(accessorID, dossierID string) bool {
|
|
// Check new RBAC system first
|
|
if CheckAccess(accessorID, dossierID, "", 'r') == nil {
|
|
return true
|
|
}
|
|
|
|
// Fallback: check old dossier_access table
|
|
var result []struct {
|
|
Status int `db:"status"`
|
|
}
|
|
err := Query(
|
|
"SELECT status FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ? AND status = 1",
|
|
[]any{accessorID, dossierID},
|
|
&result,
|
|
)
|
|
return err == nil && len(result) > 0 && result[0].Status == 1
|
|
}
|
|
|
|
// CanManageDossier returns true if accessor can manage permissions for dossier
|
|
// Falls back to old dossier_access.can_edit for backward compatibility
|
|
func CanManageDossier(accessorID, dossierID string) bool {
|
|
// Check new RBAC system first
|
|
if CheckAccess(accessorID, dossierID, "", 'm') == nil {
|
|
return true
|
|
}
|
|
|
|
// Fallback: check old dossier_access table
|
|
var result []struct {
|
|
CanEdit int `db:"can_edit"`
|
|
}
|
|
err := Query(
|
|
"SELECT can_edit FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ? AND status = 1",
|
|
[]any{accessorID, dossierID},
|
|
&result,
|
|
)
|
|
return err == nil && len(result) > 0 && result[0].CanEdit == 1
|
|
}
|
|
|
|
// 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
|
|
}
|