fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,459 @@
package rbac
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Context keys for RBAC data
type contextKey string
const (
ContextKeyUserID contextKey = "user_id"
ContextKeyTenantID contextKey = "tenant_id"
ContextKeyNamespaceID contextKey = "namespace_id"
ContextKeyPermissions contextKey = "permissions"
ContextKeyRoles contextKey = "roles"
ContextKeyUserContext contextKey = "user_context"
)
// Middleware provides RBAC middleware for Gin
type Middleware struct {
service *Service
policyEngine *PolicyEngine
}
// NewMiddleware creates a new RBAC middleware
func NewMiddleware(service *Service, policyEngine *PolicyEngine) *Middleware {
return &Middleware{
service: service,
policyEngine: policyEngine,
}
}
// ExtractUserContext extracts user context from headers/JWT and stores in context
// This middleware should run after authentication
func (m *Middleware) ExtractUserContext() gin.HandlerFunc {
return func(c *gin.Context) {
// Extract user ID from header (set by auth middleware)
userIDStr := c.GetHeader("X-User-ID")
if userIDStr == "" {
c.Next()
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.Next()
return
}
// Extract tenant ID (from header or default)
tenantIDStr := c.GetHeader("X-Tenant-ID")
if tenantIDStr == "" {
// Try to get from query param
tenantIDStr = c.Query("tenant_id")
}
if tenantIDStr == "" {
// Use default tenant slug
tenantIDStr = c.GetHeader("X-Tenant-Slug")
if tenantIDStr != "" {
tenant, err := m.service.store.GetTenantBySlug(c.Request.Context(), tenantIDStr)
if err == nil {
tenantIDStr = tenant.ID.String()
}
}
}
var tenantID uuid.UUID
if tenantIDStr != "" {
tenantID, _ = uuid.Parse(tenantIDStr)
}
// Extract namespace ID (optional)
var namespaceID *uuid.UUID
namespaceIDStr := c.GetHeader("X-Namespace-ID")
if namespaceIDStr == "" {
namespaceIDStr = c.Query("namespace_id")
}
if namespaceIDStr != "" {
if nsID, err := uuid.Parse(namespaceIDStr); err == nil {
namespaceID = &nsID
}
}
// Store in context
c.Set(string(ContextKeyUserID), userID)
c.Set(string(ContextKeyTenantID), tenantID)
if namespaceID != nil {
c.Set(string(ContextKeyNamespaceID), *namespaceID)
}
// Get effective permissions
if tenantID != uuid.Nil {
perms, err := m.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID)
if err == nil {
c.Set(string(ContextKeyPermissions), perms.Permissions)
c.Set(string(ContextKeyRoles), perms.Roles)
}
}
c.Next()
}
}
// RequirePermission requires the user to have a specific permission
func (m *Middleware) RequirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
userID, tenantID, namespaceID := m.extractIDs(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
hasPermission, err := m.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission)
if err != nil || !hasPermission {
c.JSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Insufficient permissions",
"required": permission,
})
c.Abort()
return
}
c.Next()
}
}
// RequireAnyPermission requires the user to have any of the specified permissions
func (m *Middleware) RequireAnyPermission(permissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userID, tenantID, namespaceID := m.extractIDs(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
hasPermission, err := m.service.HasAnyPermission(c.Request.Context(), userID, tenantID, namespaceID, permissions)
if err != nil || !hasPermission {
c.JSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Insufficient permissions",
"required": permissions,
})
c.Abort()
return
}
c.Next()
}
}
// RequireAllPermissions requires the user to have all specified permissions
func (m *Middleware) RequireAllPermissions(permissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userID, tenantID, namespaceID := m.extractIDs(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
hasPermission, err := m.service.HasAllPermissions(c.Request.Context(), userID, tenantID, namespaceID, permissions)
if err != nil || !hasPermission {
c.JSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Insufficient permissions - all required",
"required": permissions,
})
c.Abort()
return
}
c.Next()
}
}
// RequireNamespaceAccess requires access to the specified namespace
func (m *Middleware) RequireNamespaceAccess(operation string) gin.HandlerFunc {
return func(c *gin.Context) {
userID, tenantID, namespaceID := m.extractIDs(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
// Get namespace ID from URL param if not in context
if namespaceID == nil {
nsIDStr := c.Param("namespace_id")
if nsIDStr == "" {
nsIDStr = c.Param("namespaceId")
}
if nsIDStr != "" {
if nsID, err := uuid.Parse(nsIDStr); err == nil {
namespaceID = &nsID
}
}
}
if namespaceID == nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "bad_request",
"message": "Namespace ID required",
})
c.Abort()
return
}
result, err := m.policyEngine.EvaluateNamespaceAccess(c.Request.Context(), &NamespaceAccessRequest{
UserID: userID,
TenantID: tenantID,
NamespaceID: *namespaceID,
Operation: operation,
})
if err != nil || !result.Allowed {
reason := "access denied"
if result != nil {
reason = result.Reason
}
c.JSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Namespace access denied",
"reason": reason,
"namespace": namespaceID.String(),
})
c.Abort()
return
}
// Store namespace access result in context
c.Set("namespace_access", result)
c.Next()
}
}
// RequireLLMAccess validates LLM access based on policy
func (m *Middleware) RequireLLMAccess() gin.HandlerFunc {
return func(c *gin.Context) {
userID, tenantID, namespaceID := m.extractIDs(c)
if userID == uuid.Nil || tenantID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
// Basic LLM permission check
hasPermission, err := m.service.HasAnyPermission(c.Request.Context(), userID, tenantID, namespaceID, []string{
PermissionLLMAll,
PermissionLLMQuery,
PermissionLLMOwnQuery,
})
if err != nil || !hasPermission {
c.JSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "LLM access denied",
})
c.Abort()
return
}
c.Next()
}
}
// RequireRole requires the user to have a specific role
func (m *Middleware) RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
roles, exists := c.Get(string(ContextKeyRoles))
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
roleSlice, ok := roles.([]string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "internal_error",
"message": "Invalid role data",
})
c.Abort()
return
}
hasRole := false
for _, r := range roleSlice {
if r == role {
hasRole = true
break
}
}
if !hasRole {
c.JSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Required role missing",
"required": role,
})
c.Abort()
return
}
c.Next()
}
}
// extractIDs extracts user, tenant, and namespace IDs from context
func (m *Middleware) extractIDs(c *gin.Context) (uuid.UUID, uuid.UUID, *uuid.UUID) {
var userID, tenantID uuid.UUID
var namespaceID *uuid.UUID
if id, exists := c.Get(string(ContextKeyUserID)); exists {
userID = id.(uuid.UUID)
}
if id, exists := c.Get(string(ContextKeyTenantID)); exists {
tenantID = id.(uuid.UUID)
}
if id, exists := c.Get(string(ContextKeyNamespaceID)); exists {
nsID := id.(uuid.UUID)
namespaceID = &nsID
}
return userID, tenantID, namespaceID
}
// GetUserID retrieves user ID from Gin context
func GetUserID(c *gin.Context) uuid.UUID {
if id, exists := c.Get(string(ContextKeyUserID)); exists {
return id.(uuid.UUID)
}
return uuid.Nil
}
// GetTenantID retrieves tenant ID from Gin context
func GetTenantID(c *gin.Context) uuid.UUID {
if id, exists := c.Get(string(ContextKeyTenantID)); exists {
return id.(uuid.UUID)
}
return uuid.Nil
}
// GetNamespaceID retrieves namespace ID from Gin context
func GetNamespaceID(c *gin.Context) *uuid.UUID {
if id, exists := c.Get(string(ContextKeyNamespaceID)); exists {
nsID := id.(uuid.UUID)
return &nsID
}
return nil
}
// GetPermissions retrieves permissions from Gin context
func GetPermissions(c *gin.Context) []string {
if perms, exists := c.Get(string(ContextKeyPermissions)); exists {
return perms.([]string)
}
return []string{}
}
// GetRoles retrieves roles from Gin context
func GetRoles(c *gin.Context) []string {
if roles, exists := c.Get(string(ContextKeyRoles)); exists {
return roles.([]string)
}
return []string{}
}
// ContextWithUserID adds user ID to context
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, ContextKeyUserID, userID)
}
// ContextWithTenantID adds tenant ID to context
func ContextWithTenantID(ctx context.Context, tenantID uuid.UUID) context.Context {
return context.WithValue(ctx, ContextKeyTenantID, tenantID)
}
// ContextWithNamespaceID adds namespace ID to context
func ContextWithNamespaceID(ctx context.Context, namespaceID uuid.UUID) context.Context {
return context.WithValue(ctx, ContextKeyNamespaceID, namespaceID)
}
// UserIDFromContext retrieves user ID from standard context
func UserIDFromContext(ctx context.Context) uuid.UUID {
if id, ok := ctx.Value(ContextKeyUserID).(uuid.UUID); ok {
return id
}
return uuid.Nil
}
// TenantIDFromContext retrieves tenant ID from standard context
func TenantIDFromContext(ctx context.Context) uuid.UUID {
if id, ok := ctx.Value(ContextKeyTenantID).(uuid.UUID); ok {
return id
}
return uuid.Nil
}
// NamespaceIDFromContext retrieves namespace ID from standard context
func NamespaceIDFromContext(ctx context.Context) *uuid.UUID {
if id, ok := ctx.Value(ContextKeyNamespaceID).(uuid.UUID); ok {
return &id
}
return nil
}
// HasPermissionFromHeader checks permission from header-based context (for API keys)
func (m *Middleware) HasPermissionFromHeader(c *gin.Context, permission string) bool {
// Check X-API-Permissions header (set by API key auth)
permsHeader := c.GetHeader("X-API-Permissions")
if permsHeader != "" {
perms := strings.Split(permsHeader, ",")
for _, p := range perms {
p = strings.TrimSpace(p)
if p == permission {
return true
}
// Wildcard check
if strings.HasSuffix(p, ":*") {
prefix := strings.TrimSuffix(p, "*")
if strings.HasPrefix(permission, prefix) {
return true
}
}
}
}
return false
}

View File

@@ -0,0 +1,197 @@
package rbac
import (
"time"
"github.com/google/uuid"
)
// IsolationLevel defines namespace isolation strictness
type IsolationLevel string
const (
IsolationStrict IsolationLevel = "strict"
IsolationShared IsolationLevel = "shared"
IsolationPublic IsolationLevel = "public"
)
// DataClassification defines data sensitivity levels
type DataClassification string
const (
ClassificationPublic DataClassification = "public"
ClassificationInternal DataClassification = "internal"
ClassificationConfidential DataClassification = "confidential"
ClassificationRestricted DataClassification = "restricted"
)
// TenantStatus defines tenant status
type TenantStatus string
const (
TenantStatusActive TenantStatus = "active"
TenantStatusSuspended TenantStatus = "suspended"
TenantStatusInactive TenantStatus = "inactive"
)
// PIIRedactionLevel defines PII redaction strictness
type PIIRedactionLevel string
const (
PIIRedactionStrict PIIRedactionLevel = "strict"
PIIRedactionModerate PIIRedactionLevel = "moderate"
PIIRedactionMinimal PIIRedactionLevel = "minimal"
PIIRedactionNone PIIRedactionLevel = "none"
)
// Tenant represents a customer/organization (Mandant)
type Tenant struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Settings map[string]any `json:"settings" db:"settings"`
MaxUsers int `json:"max_users" db:"max_users"`
LLMQuotaMonthly int `json:"llm_quota_monthly" db:"llm_quota_monthly"`
Status TenantStatus `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Namespace represents a department/division within a tenant (z.B. Finance, HR, IT)
type Namespace struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
ParentNamespaceID *uuid.UUID `json:"parent_namespace_id,omitempty" db:"parent_namespace_id"`
IsolationLevel IsolationLevel `json:"isolation_level" db:"isolation_level"`
DataClassification DataClassification `json:"data_classification" db:"data_classification"`
Metadata map[string]any `json:"metadata,omitempty" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Role defines a set of permissions
type Role struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` // nil for system roles
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
Permissions []string `json:"permissions" db:"permissions"`
IsSystemRole bool `json:"is_system_role" db:"is_system_role"`
HierarchyLevel int `json:"hierarchy_level" db:"hierarchy_level"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// UserRole represents a user's role assignment with optional namespace scope
type UserRole struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
RoleID uuid.UUID `json:"role_id" db:"role_id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"` // nil = tenant-wide
GrantedBy uuid.UUID `json:"granted_by" db:"granted_by"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Joined fields (populated by queries)
RoleName string `json:"role_name,omitempty" db:"role_name"`
RolePermissions []string `json:"role_permissions,omitempty" db:"role_permissions"`
NamespaceName string `json:"namespace_name,omitempty" db:"namespace_name"`
}
// LLMPolicy defines access controls for LLM operations
type LLMPolicy struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"`
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
AllowedDataCategories []string `json:"allowed_data_categories" db:"allowed_data_categories"`
BlockedDataCategories []string `json:"blocked_data_categories" db:"blocked_data_categories"`
RequirePIIRedaction bool `json:"require_pii_redaction" db:"require_pii_redaction"`
PIIRedactionLevel PIIRedactionLevel `json:"pii_redaction_level" db:"pii_redaction_level"`
AllowedModels []string `json:"allowed_models" db:"allowed_models"`
MaxTokensPerRequest int `json:"max_tokens_per_request" db:"max_tokens_per_request"`
MaxRequestsPerDay int `json:"max_requests_per_day" db:"max_requests_per_day"`
MaxRequestsPerHour int `json:"max_requests_per_hour" db:"max_requests_per_hour"`
IsActive bool `json:"is_active" db:"is_active"`
Priority int `json:"priority" db:"priority"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// APIKey represents an API key for SDK access
type APIKey struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
KeyHash string `json:"-" db:"key_hash"` // Never expose
KeyPrefix string `json:"key_prefix" db:"key_prefix"`
Permissions []string `json:"permissions" db:"permissions"`
NamespaceRestrictions []uuid.UUID `json:"namespace_restrictions,omitempty" db:"namespace_restrictions"`
RateLimitPerHour int `json:"rate_limit_per_hour" db:"rate_limit_per_hour"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedBy uuid.UUID `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// EffectivePermissions represents a user's computed permissions
type EffectivePermissions struct {
UserID uuid.UUID `json:"user_id"`
TenantID uuid.UUID `json:"tenant_id"`
NamespaceID *uuid.UUID `json:"namespace_id,omitempty"`
Permissions []string `json:"permissions"`
Roles []string `json:"roles"`
LLMPolicy *LLMPolicy `json:"llm_policy,omitempty"`
Namespaces []NamespaceAccess `json:"namespaces,omitempty"`
}
// NamespaceAccess represents a user's access to a namespace
type NamespaceAccess struct {
NamespaceID uuid.UUID `json:"namespace_id"`
NamespaceName string `json:"namespace_name"`
NamespaceSlug string `json:"namespace_slug"`
DataClassification DataClassification `json:"data_classification"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
}
// System role names (predefined)
const (
RoleComplianceExecutive = "compliance_executive"
RoleComplianceOfficer = "compliance_officer"
RoleDataProtectionOfficer = "data_protection_officer"
RoleNamespaceAdmin = "namespace_admin"
RoleAuditor = "auditor"
RoleComplianceUser = "compliance_user"
)
// Common permission patterns
const (
PermissionComplianceAll = "compliance:*"
PermissionComplianceRead = "compliance:read"
PermissionComplianceWrite = "compliance:write"
PermissionComplianceOwnRead = "compliance:own:read"
PermissionAuditAll = "audit:*"
PermissionAuditRead = "audit:read"
PermissionAuditLogRead = "audit:log:read"
PermissionLLMAll = "llm:*"
PermissionLLMQuery = "llm:query:execute"
PermissionLLMOwnQuery = "llm:own:query"
PermissionNamespaceRead = "namespace:read"
PermissionNamespaceOwnAdmin = "namespace:own:admin"
)
// Data categories for LLM access control
const (
DataCategorySalary = "salary"
DataCategoryHealth = "health"
DataCategoryPersonal = "personal"
DataCategoryFinancial = "financial"
DataCategoryLegal = "legal"
DataCategoryHR = "hr"
)

View File

@@ -0,0 +1,395 @@
package rbac
import (
"context"
"strings"
"github.com/google/uuid"
)
// PolicyEngine provides advanced policy evaluation
type PolicyEngine struct {
service *Service
store *Store
}
// NewPolicyEngine creates a new policy engine
func NewPolicyEngine(service *Service, store *Store) *PolicyEngine {
return &PolicyEngine{
service: service,
store: store,
}
}
// LLMAccessRequest represents a request to access LLM functionality
type LLMAccessRequest struct {
UserID uuid.UUID
TenantID uuid.UUID
NamespaceID *uuid.UUID
Model string
DataCategories []string
TokensRequested int
Operation string // "query", "completion", "embedding", "analysis"
}
// LLMAccessResult represents the result of an LLM access check
type LLMAccessResult struct {
Allowed bool
Reason string
Policy *LLMPolicy
RequirePIIRedaction bool
PIIRedactionLevel PIIRedactionLevel
MaxTokens int
BlockedCategories []string
}
// EvaluateLLMAccess evaluates whether an LLM request should be allowed
func (pe *PolicyEngine) EvaluateLLMAccess(ctx context.Context, req *LLMAccessRequest) (*LLMAccessResult, error) {
result := &LLMAccessResult{
Allowed: false,
RequirePIIRedaction: true,
PIIRedactionLevel: PIIRedactionStrict,
MaxTokens: 4000,
BlockedCategories: []string{},
}
// 1. Check base permission for LLM access
hasPermission, err := pe.service.HasAnyPermission(ctx, req.UserID, req.TenantID, req.NamespaceID, []string{
PermissionLLMAll,
PermissionLLMQuery,
PermissionLLMOwnQuery,
})
if err != nil {
return result, err
}
if !hasPermission {
result.Reason = "no LLM permission"
return result, nil
}
// 2. Get effective policy
policy, err := pe.store.GetEffectiveLLMPolicy(ctx, req.TenantID, req.NamespaceID)
if err != nil {
return result, err
}
result.Policy = policy
// No policy = use defaults and allow
if policy == nil {
result.Allowed = true
result.Reason = "no policy restrictions"
return result, nil
}
// 3. Check model restrictions
if len(policy.AllowedModels) > 0 {
modelAllowed := false
for _, allowed := range policy.AllowedModels {
if allowed == req.Model || strings.HasPrefix(req.Model, allowed+":") || strings.HasPrefix(req.Model, allowed+"-") {
modelAllowed = true
break
}
}
if !modelAllowed {
result.Reason = "model not allowed: " + req.Model
return result, nil
}
}
// 4. Check data categories
for _, category := range req.DataCategories {
// Check if blocked
for _, blocked := range policy.BlockedDataCategories {
if blocked == category {
result.BlockedCategories = append(result.BlockedCategories, category)
}
}
// Check if allowed (if allowlist is defined)
if len(policy.AllowedDataCategories) > 0 {
allowed := false
for _, a := range policy.AllowedDataCategories {
if a == category {
allowed = true
break
}
}
if !allowed {
result.BlockedCategories = append(result.BlockedCategories, category)
}
}
}
if len(result.BlockedCategories) > 0 {
result.Reason = "blocked data categories: " + strings.Join(result.BlockedCategories, ", ")
return result, nil
}
// 5. Check token limits
if req.TokensRequested > policy.MaxTokensPerRequest {
result.Reason = "tokens requested exceeds limit"
return result, nil
}
result.MaxTokens = policy.MaxTokensPerRequest
// 6. Set PII redaction requirements
result.RequirePIIRedaction = policy.RequirePIIRedaction
result.PIIRedactionLevel = policy.PIIRedactionLevel
// All checks passed
result.Allowed = true
result.Reason = "policy check passed"
return result, nil
}
// NamespaceAccessRequest represents a request to access a namespace
type NamespaceAccessRequest struct {
UserID uuid.UUID
TenantID uuid.UUID
NamespaceID uuid.UUID
Operation string // "read", "write", "admin"
}
// NamespaceAccessResult represents the result of a namespace access check
type NamespaceAccessResult struct {
Allowed bool
Reason string
DataClassification DataClassification
IsolationLevel IsolationLevel
Permissions []string
}
// EvaluateNamespaceAccess evaluates whether a namespace operation should be allowed
func (pe *PolicyEngine) EvaluateNamespaceAccess(ctx context.Context, req *NamespaceAccessRequest) (*NamespaceAccessResult, error) {
result := &NamespaceAccessResult{
Allowed: false,
Permissions: []string{},
}
// Get namespace details
ns, err := pe.store.GetNamespace(ctx, req.NamespaceID)
if err != nil {
result.Reason = "namespace not found"
return result, ErrNamespaceNotFound
}
result.DataClassification = ns.DataClassification
result.IsolationLevel = ns.IsolationLevel
// Check if user has any roles in this namespace
userRoles, err := pe.store.GetUserRolesForNamespace(ctx, req.UserID, req.TenantID, &req.NamespaceID)
if err != nil {
return result, err
}
if len(userRoles) == 0 {
// Check for tenant-wide roles
tenantRoles, err := pe.store.GetUserRolesForNamespace(ctx, req.UserID, req.TenantID, nil)
if err != nil {
return result, err
}
if len(tenantRoles) == 0 {
result.Reason = "no access to namespace"
return result, nil
}
userRoles = tenantRoles
}
// Collect permissions
permSet := make(map[string]bool)
for _, ur := range userRoles {
for _, perm := range ur.RolePermissions {
permSet[perm] = true
}
}
for perm := range permSet {
result.Permissions = append(result.Permissions, perm)
}
// Check operation-specific permission
var requiredPermission string
switch req.Operation {
case "read":
requiredPermission = "namespace:read"
case "write":
requiredPermission = "namespace:write"
case "admin":
requiredPermission = "namespace:own:admin"
default:
requiredPermission = "namespace:read"
}
hasPermission := pe.service.checkPermission(result.Permissions, requiredPermission)
if !hasPermission {
// Check for broader permissions
hasPermission = pe.service.checkPermission(result.Permissions, PermissionComplianceAll) ||
pe.service.checkPermission(result.Permissions, "namespace:*")
}
if !hasPermission {
result.Reason = "insufficient permissions for operation: " + req.Operation
return result, nil
}
result.Allowed = true
result.Reason = "access granted"
return result, nil
}
// DataAccessRequest represents a request to access data
type DataAccessRequest struct {
UserID uuid.UUID
TenantID uuid.UUID
NamespaceID *uuid.UUID
ResourceType string // "control", "evidence", "audit", "policy"
ResourceID *uuid.UUID
Operation string // "read", "create", "update", "delete"
DataCategories []string
}
// DataAccessResult represents the result of a data access check
type DataAccessResult struct {
Allowed bool
Reason string
RequireAuditLog bool
AllowedCategories []string
DeniedCategories []string
}
// EvaluateDataAccess evaluates whether a data operation should be allowed
func (pe *PolicyEngine) EvaluateDataAccess(ctx context.Context, req *DataAccessRequest) (*DataAccessResult, error) {
result := &DataAccessResult{
Allowed: false,
RequireAuditLog: true,
AllowedCategories: []string{},
DeniedCategories: []string{},
}
// Build required permission based on resource type and operation
requiredPermission := req.ResourceType + ":" + req.Operation
// Check permission
hasPermission, err := pe.service.HasAnyPermission(ctx, req.UserID, req.TenantID, req.NamespaceID, []string{
requiredPermission,
req.ResourceType + ":*",
PermissionComplianceAll,
})
if err != nil {
return result, err
}
if !hasPermission {
result.Reason = "missing permission: " + requiredPermission
return result, nil
}
// Check namespace access if namespace is specified
if req.NamespaceID != nil {
nsResult, err := pe.EvaluateNamespaceAccess(ctx, &NamespaceAccessRequest{
UserID: req.UserID,
TenantID: req.TenantID,
NamespaceID: *req.NamespaceID,
Operation: req.Operation,
})
if err != nil {
return result, err
}
if !nsResult.Allowed {
result.Reason = "namespace access denied: " + nsResult.Reason
return result, nil
}
}
// Check data categories
if len(req.DataCategories) > 0 {
policy, _ := pe.store.GetEffectiveLLMPolicy(ctx, req.TenantID, req.NamespaceID)
if policy != nil {
for _, category := range req.DataCategories {
blocked := false
// Check blocked list
for _, b := range policy.BlockedDataCategories {
if b == category {
blocked = true
break
}
}
// Check allowed list if specified
if !blocked && len(policy.AllowedDataCategories) > 0 {
allowed := false
for _, a := range policy.AllowedDataCategories {
if a == category {
allowed = true
break
}
}
if !allowed {
blocked = true
}
}
if blocked {
result.DeniedCategories = append(result.DeniedCategories, category)
} else {
result.AllowedCategories = append(result.AllowedCategories, category)
}
}
if len(result.DeniedCategories) > 0 {
result.Reason = "blocked data categories: " + strings.Join(result.DeniedCategories, ", ")
return result, nil
}
}
}
result.Allowed = true
result.Reason = "access granted"
return result, nil
}
// GetUserContext returns full context for a user including all permissions and policies
func (pe *PolicyEngine) GetUserContext(ctx context.Context, userID, tenantID uuid.UUID) (*UserContext, error) {
perms, err := pe.service.GetEffectivePermissions(ctx, userID, tenantID, nil)
if err != nil {
return nil, err
}
tenant, err := pe.store.GetTenant(ctx, tenantID)
if err != nil {
return nil, err
}
namespaces, err := pe.service.GetUserAccessibleNamespaces(ctx, userID, tenantID)
if err != nil {
return nil, err
}
return &UserContext{
UserID: userID,
TenantID: tenantID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
Roles: perms.Roles,
Permissions: perms.Permissions,
Namespaces: namespaces,
LLMPolicy: perms.LLMPolicy,
}, nil
}
// UserContext represents complete context for a user
type UserContext struct {
UserID uuid.UUID `json:"user_id"`
TenantID uuid.UUID `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
Namespaces []*Namespace `json:"namespaces"`
LLMPolicy *LLMPolicy `json:"llm_policy,omitempty"`
}

View File

@@ -0,0 +1,360 @@
package rbac
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
)
var (
ErrTenantNotFound = errors.New("tenant not found")
ErrNamespaceNotFound = errors.New("namespace not found")
ErrRoleNotFound = errors.New("role not found")
ErrPermissionDenied = errors.New("permission denied")
ErrInvalidNamespace = errors.New("invalid namespace access")
ErrDataCategoryBlocked = errors.New("data category blocked by policy")
ErrLLMQuotaExceeded = errors.New("LLM quota exceeded")
ErrModelNotAllowed = errors.New("model not allowed by policy")
)
// Service provides RBAC business logic
type Service struct {
store *Store
}
// NewService creates a new RBAC service
func NewService(store *Store) *Service {
return &Service{store: store}
}
// GetEffectivePermissions computes all effective permissions for a user
func (s *Service) GetEffectivePermissions(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) (*EffectivePermissions, error) {
// Get user roles
userRoles, err := s.store.GetUserRolesForNamespace(ctx, userID, tenantID, namespaceID)
if err != nil {
return nil, err
}
// Aggregate permissions and roles
permissionSet := make(map[string]bool)
roleSet := make(map[string]bool)
for _, ur := range userRoles {
roleSet[ur.RoleName] = true
for _, perm := range ur.RolePermissions {
permissionSet[perm] = true
}
}
// Convert to slices
permissions := make([]string, 0, len(permissionSet))
for perm := range permissionSet {
permissions = append(permissions, perm)
}
roles := make([]string, 0, len(roleSet))
for role := range roleSet {
roles = append(roles, role)
}
// Get effective LLM policy
llmPolicy, _ := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID)
// Get all accessible namespaces
allUserRoles, err := s.store.GetUserRoles(ctx, userID, tenantID)
if err != nil {
return nil, err
}
// Build namespace access list
namespaceAccessMap := make(map[uuid.UUID]*NamespaceAccess)
for _, ur := range allUserRoles {
var nsID uuid.UUID
if ur.NamespaceID != nil {
nsID = *ur.NamespaceID
} else {
continue // Tenant-wide roles don't have specific namespace
}
access, exists := namespaceAccessMap[nsID]
if !exists {
// Get namespace details
ns, err := s.store.GetNamespace(ctx, nsID)
if err != nil {
continue
}
access = &NamespaceAccess{
NamespaceID: ns.ID,
NamespaceName: ns.Name,
NamespaceSlug: ns.Slug,
DataClassification: ns.DataClassification,
Roles: []string{},
Permissions: []string{},
}
namespaceAccessMap[nsID] = access
}
access.Roles = append(access.Roles, ur.RoleName)
access.Permissions = append(access.Permissions, ur.RolePermissions...)
}
namespaces := make([]NamespaceAccess, 0, len(namespaceAccessMap))
for _, access := range namespaceAccessMap {
namespaces = append(namespaces, *access)
}
return &EffectivePermissions{
UserID: userID,
TenantID: tenantID,
NamespaceID: namespaceID,
Permissions: permissions,
Roles: roles,
LLMPolicy: llmPolicy,
Namespaces: namespaces,
}, nil
}
// HasPermission checks if user has a specific permission
func (s *Service) HasPermission(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, permission string) (bool, error) {
perms, err := s.GetEffectivePermissions(ctx, userID, tenantID, namespaceID)
if err != nil {
return false, err
}
return s.checkPermission(perms.Permissions, permission), nil
}
// checkPermission checks if a permission is granted given a list of permissions
func (s *Service) checkPermission(permissions []string, required string) bool {
for _, perm := range permissions {
// Exact match
if perm == required {
return true
}
// Wildcard match (e.g., "compliance:*" matches "compliance:read")
if strings.HasSuffix(perm, ":*") {
prefix := strings.TrimSuffix(perm, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
// Full wildcard (e.g., "compliance:*:read" matches "compliance:privacy:read")
if strings.Contains(perm, ":*:") {
parts := strings.Split(perm, ":*:")
if len(parts) == 2 {
if strings.HasPrefix(required, parts[0]+":") && strings.HasSuffix(required, ":"+parts[1]) {
return true
}
}
}
// "own" namespace handling (e.g., "compliance:own:read" when in own namespace)
// This requires context about whether the user is in their "own" namespace
// For simplicity, we'll handle "own" as a prefix match for now
if strings.Contains(perm, ":own:") {
ownPerm := strings.Replace(perm, ":own:", ":", 1)
if ownPerm == required || strings.HasPrefix(required, strings.TrimSuffix(ownPerm, "*")) {
return true
}
}
}
return false
}
// HasAnyPermission checks if user has any of the specified permissions
func (s *Service) HasAnyPermission(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, permissions []string) (bool, error) {
perms, err := s.GetEffectivePermissions(ctx, userID, tenantID, namespaceID)
if err != nil {
return false, err
}
for _, required := range permissions {
if s.checkPermission(perms.Permissions, required) {
return true, nil
}
}
return false, nil
}
// HasAllPermissions checks if user has all specified permissions
func (s *Service) HasAllPermissions(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, permissions []string) (bool, error) {
perms, err := s.GetEffectivePermissions(ctx, userID, tenantID, namespaceID)
if err != nil {
return false, err
}
for _, required := range permissions {
if !s.checkPermission(perms.Permissions, required) {
return false, nil
}
}
return true, nil
}
// CanAccessNamespace checks if user can access a namespace
func (s *Service) CanAccessNamespace(ctx context.Context, userID, tenantID, namespaceID uuid.UUID) (bool, error) {
userRoles, err := s.store.GetUserRolesForNamespace(ctx, userID, tenantID, &namespaceID)
if err != nil {
return false, err
}
return len(userRoles) > 0, nil
}
// CanAccessDataCategory checks if user can access a data category via LLM
func (s *Service) CanAccessDataCategory(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, category string) (bool, string, error) {
policy, err := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID)
if err != nil {
return false, "", err
}
// No policy = allow all
if policy == nil {
return true, "", nil
}
// Check blocked categories
for _, blocked := range policy.BlockedDataCategories {
if blocked == category {
return false, "data category is blocked by policy", nil
}
}
// If allowed categories are specified, check if category is in the list
if len(policy.AllowedDataCategories) > 0 {
allowed := false
for _, a := range policy.AllowedDataCategories {
if a == category {
allowed = true
break
}
}
if !allowed {
return false, "data category is not in allowed list", nil
}
}
return true, "", nil
}
// CanUseModel checks if a model is allowed by policy
func (s *Service) CanUseModel(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID, model string) (bool, error) {
policy, err := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID)
if err != nil {
return false, err
}
// No policy = allow all models
if policy == nil || len(policy.AllowedModels) == 0 {
return true, nil
}
// Check if model is in allowed list
for _, allowed := range policy.AllowedModels {
if allowed == model {
return true, nil
}
// Partial match for model families (e.g., "qwen2.5" matches "qwen2.5:7b")
if strings.HasPrefix(model, allowed+":") || strings.HasPrefix(model, allowed+"-") {
return true, nil
}
}
return false, nil
}
// GetPIIRedactionLevel returns the PII redaction level for a namespace
func (s *Service) GetPIIRedactionLevel(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (PIIRedactionLevel, bool, error) {
policy, err := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID)
if err != nil {
return PIIRedactionStrict, true, err
}
// No policy = use strict defaults
if policy == nil {
return PIIRedactionStrict, true, nil
}
return policy.PIIRedactionLevel, policy.RequirePIIRedaction, nil
}
// GetUserAccessibleNamespaces returns all namespaces a user can access
func (s *Service) GetUserAccessibleNamespaces(ctx context.Context, userID, tenantID uuid.UUID) ([]*Namespace, error) {
userRoles, err := s.store.GetUserRoles(ctx, userID, tenantID)
if err != nil {
return nil, err
}
// Get unique namespace IDs
namespaceIDs := make(map[uuid.UUID]bool)
hasTenantWideRole := false
for _, ur := range userRoles {
if ur.NamespaceID != nil {
namespaceIDs[*ur.NamespaceID] = true
} else {
hasTenantWideRole = true
}
}
// If user has tenant-wide role, return all namespaces
if hasTenantWideRole {
return s.store.ListNamespaces(ctx, tenantID)
}
// Otherwise, return only specific namespaces
var namespaces []*Namespace
for nsID := range namespaceIDs {
ns, err := s.store.GetNamespace(ctx, nsID)
if err != nil {
continue
}
namespaces = append(namespaces, ns)
}
return namespaces, nil
}
// AssignRoleToUser assigns a role to a user
func (s *Service) AssignRoleToUser(ctx context.Context, ur *UserRole, grantorUserID uuid.UUID) error {
// Check if grantor has permission to assign roles
hasPermission, err := s.HasAnyPermission(ctx, grantorUserID, ur.TenantID, ur.NamespaceID, []string{
"rbac:assign",
"namespace:own:admin",
PermissionComplianceAll,
})
if err != nil {
return err
}
if !hasPermission {
return ErrPermissionDenied
}
ur.GrantedBy = grantorUserID
return s.store.AssignRole(ctx, ur)
}
// RevokeRoleFromUser revokes a role from a user
func (s *Service) RevokeRoleFromUser(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID, revokerUserID uuid.UUID) error {
// Check if revoker has permission to revoke roles
hasPermission, err := s.HasAnyPermission(ctx, revokerUserID, tenantID, namespaceID, []string{
"rbac:revoke",
"namespace:own:admin",
PermissionComplianceAll,
})
if err != nil {
return err
}
if !hasPermission {
return ErrPermissionDenied
}
return s.store.RevokeRole(ctx, userID, roleID, tenantID, namespaceID)
}

View File

@@ -0,0 +1,651 @@
package rbac
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides database operations for RBAC entities
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new RBAC store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Tenant Operations
// ============================================================================
// CreateTenant creates a new tenant
func (s *Store) CreateTenant(ctx context.Context, tenant *Tenant) error {
tenant.ID = uuid.New()
tenant.CreatedAt = time.Now().UTC()
tenant.UpdatedAt = tenant.CreatedAt
if tenant.Status == "" {
tenant.Status = TenantStatusActive
}
if tenant.Settings == nil {
tenant.Settings = make(map[string]any)
}
settingsJSON, err := json.Marshal(tenant.Settings)
if err != nil {
return fmt.Errorf("failed to marshal settings: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO compliance_tenants (id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, tenant.ID, tenant.Name, tenant.Slug, settingsJSON, tenant.MaxUsers, tenant.LLMQuotaMonthly, tenant.Status, tenant.CreatedAt, tenant.UpdatedAt)
return err
}
// GetTenant retrieves a tenant by ID
func (s *Store) GetTenant(ctx context.Context, id uuid.UUID) (*Tenant, error) {
var tenant Tenant
var settingsJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at
FROM compliance_tenants
WHERE id = $1
`, id).Scan(
&tenant.ID, &tenant.Name, &tenant.Slug, &settingsJSON,
&tenant.MaxUsers, &tenant.LLMQuotaMonthly, &tenant.Status,
&tenant.CreatedAt, &tenant.UpdatedAt,
)
if err != nil {
return nil, err
}
if err := json.Unmarshal(settingsJSON, &tenant.Settings); err != nil {
tenant.Settings = make(map[string]any)
}
return &tenant, nil
}
// GetTenantBySlug retrieves a tenant by slug
func (s *Store) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) {
var tenant Tenant
var settingsJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at
FROM compliance_tenants
WHERE slug = $1
`, slug).Scan(
&tenant.ID, &tenant.Name, &tenant.Slug, &settingsJSON,
&tenant.MaxUsers, &tenant.LLMQuotaMonthly, &tenant.Status,
&tenant.CreatedAt, &tenant.UpdatedAt,
)
if err != nil {
return nil, err
}
if err := json.Unmarshal(settingsJSON, &tenant.Settings); err != nil {
tenant.Settings = make(map[string]any)
}
return &tenant, nil
}
// ListTenants lists all tenants
func (s *Store) ListTenants(ctx context.Context) ([]*Tenant, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at
FROM compliance_tenants
ORDER BY name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var tenants []*Tenant
for rows.Next() {
var tenant Tenant
var settingsJSON []byte
err := rows.Scan(
&tenant.ID, &tenant.Name, &tenant.Slug, &settingsJSON,
&tenant.MaxUsers, &tenant.LLMQuotaMonthly, &tenant.Status,
&tenant.CreatedAt, &tenant.UpdatedAt,
)
if err != nil {
continue
}
if err := json.Unmarshal(settingsJSON, &tenant.Settings); err != nil {
tenant.Settings = make(map[string]any)
}
tenants = append(tenants, &tenant)
}
return tenants, nil
}
// UpdateTenant updates a tenant
func (s *Store) UpdateTenant(ctx context.Context, tenant *Tenant) error {
tenant.UpdatedAt = time.Now().UTC()
settingsJSON, err := json.Marshal(tenant.Settings)
if err != nil {
return fmt.Errorf("failed to marshal settings: %w", err)
}
_, err = s.pool.Exec(ctx, `
UPDATE compliance_tenants
SET name = $2, slug = $3, settings = $4, max_users = $5, llm_quota_monthly = $6, status = $7, updated_at = $8
WHERE id = $1
`, tenant.ID, tenant.Name, tenant.Slug, settingsJSON, tenant.MaxUsers, tenant.LLMQuotaMonthly, tenant.Status, tenant.UpdatedAt)
return err
}
// ============================================================================
// Namespace Operations
// ============================================================================
// CreateNamespace creates a new namespace
func (s *Store) CreateNamespace(ctx context.Context, ns *Namespace) error {
ns.ID = uuid.New()
ns.CreatedAt = time.Now().UTC()
ns.UpdatedAt = ns.CreatedAt
if ns.IsolationLevel == "" {
ns.IsolationLevel = IsolationStrict
}
if ns.DataClassification == "" {
ns.DataClassification = ClassificationInternal
}
if ns.Metadata == nil {
ns.Metadata = make(map[string]any)
}
metadataJSON, err := json.Marshal(ns.Metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO compliance_namespaces (id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, ns.ID, ns.TenantID, ns.Name, ns.Slug, ns.ParentNamespaceID, ns.IsolationLevel, ns.DataClassification, metadataJSON, ns.CreatedAt, ns.UpdatedAt)
return err
}
// GetNamespace retrieves a namespace by ID
func (s *Store) GetNamespace(ctx context.Context, id uuid.UUID) (*Namespace, error) {
var ns Namespace
var metadataJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at
FROM compliance_namespaces
WHERE id = $1
`, id).Scan(
&ns.ID, &ns.TenantID, &ns.Name, &ns.Slug, &ns.ParentNamespaceID,
&ns.IsolationLevel, &ns.DataClassification, &metadataJSON,
&ns.CreatedAt, &ns.UpdatedAt,
)
if err != nil {
return nil, err
}
if err := json.Unmarshal(metadataJSON, &ns.Metadata); err != nil {
ns.Metadata = make(map[string]any)
}
return &ns, nil
}
// GetNamespaceBySlug retrieves a namespace by tenant and slug
func (s *Store) GetNamespaceBySlug(ctx context.Context, tenantID uuid.UUID, slug string) (*Namespace, error) {
var ns Namespace
var metadataJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at
FROM compliance_namespaces
WHERE tenant_id = $1 AND slug = $2
`, tenantID, slug).Scan(
&ns.ID, &ns.TenantID, &ns.Name, &ns.Slug, &ns.ParentNamespaceID,
&ns.IsolationLevel, &ns.DataClassification, &metadataJSON,
&ns.CreatedAt, &ns.UpdatedAt,
)
if err != nil {
return nil, err
}
if err := json.Unmarshal(metadataJSON, &ns.Metadata); err != nil {
ns.Metadata = make(map[string]any)
}
return &ns, nil
}
// ListNamespaces lists namespaces for a tenant
func (s *Store) ListNamespaces(ctx context.Context, tenantID uuid.UUID) ([]*Namespace, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at
FROM compliance_namespaces
WHERE tenant_id = $1
ORDER BY name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var namespaces []*Namespace
for rows.Next() {
var ns Namespace
var metadataJSON []byte
err := rows.Scan(
&ns.ID, &ns.TenantID, &ns.Name, &ns.Slug, &ns.ParentNamespaceID,
&ns.IsolationLevel, &ns.DataClassification, &metadataJSON,
&ns.CreatedAt, &ns.UpdatedAt,
)
if err != nil {
continue
}
if err := json.Unmarshal(metadataJSON, &ns.Metadata); err != nil {
ns.Metadata = make(map[string]any)
}
namespaces = append(namespaces, &ns)
}
return namespaces, nil
}
// ============================================================================
// Role Operations
// ============================================================================
// CreateRole creates a new role
func (s *Store) CreateRole(ctx context.Context, role *Role) error {
role.ID = uuid.New()
role.CreatedAt = time.Now().UTC()
role.UpdatedAt = role.CreatedAt
_, err := s.pool.Exec(ctx, `
INSERT INTO compliance_roles (id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, role.ID, role.TenantID, role.Name, role.Description, role.Permissions, role.IsSystemRole, role.HierarchyLevel, role.CreatedAt, role.UpdatedAt)
return err
}
// GetRole retrieves a role by ID
func (s *Store) GetRole(ctx context.Context, id uuid.UUID) (*Role, error) {
var role Role
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
FROM compliance_roles
WHERE id = $1
`, id).Scan(
&role.ID, &role.TenantID, &role.Name, &role.Description,
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
&role.CreatedAt, &role.UpdatedAt,
)
return &role, err
}
// GetRoleByName retrieves a role by tenant and name
func (s *Store) GetRoleByName(ctx context.Context, tenantID *uuid.UUID, name string) (*Role, error) {
var role Role
query := `
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
FROM compliance_roles
WHERE name = $1 AND (tenant_id = $2 OR (tenant_id IS NULL AND is_system_role = TRUE))
`
err := s.pool.QueryRow(ctx, query, name, tenantID).Scan(
&role.ID, &role.TenantID, &role.Name, &role.Description,
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
&role.CreatedAt, &role.UpdatedAt,
)
return &role, err
}
// ListRoles lists roles for a tenant (including system roles)
func (s *Store) ListRoles(ctx context.Context, tenantID *uuid.UUID) ([]*Role, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
FROM compliance_roles
WHERE tenant_id = $1 OR is_system_role = TRUE
ORDER BY hierarchy_level, name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var roles []*Role
for rows.Next() {
var role Role
err := rows.Scan(
&role.ID, &role.TenantID, &role.Name, &role.Description,
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
&role.CreatedAt, &role.UpdatedAt,
)
if err != nil {
continue
}
roles = append(roles, &role)
}
return roles, nil
}
// ListSystemRoles lists all system roles
func (s *Store) ListSystemRoles(ctx context.Context) ([]*Role, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at
FROM compliance_roles
WHERE is_system_role = TRUE
ORDER BY hierarchy_level, name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var roles []*Role
for rows.Next() {
var role Role
err := rows.Scan(
&role.ID, &role.TenantID, &role.Name, &role.Description,
&role.Permissions, &role.IsSystemRole, &role.HierarchyLevel,
&role.CreatedAt, &role.UpdatedAt,
)
if err != nil {
continue
}
roles = append(roles, &role)
}
return roles, nil
}
// ============================================================================
// User Role Operations
// ============================================================================
// AssignRole assigns a role to a user
func (s *Store) AssignRole(ctx context.Context, ur *UserRole) error {
ur.ID = uuid.New()
ur.CreatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO compliance_user_roles (id, user_id, role_id, tenant_id, namespace_id, granted_by, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id, role_id, tenant_id, namespace_id) DO UPDATE SET
granted_by = EXCLUDED.granted_by,
expires_at = EXCLUDED.expires_at
`, ur.ID, ur.UserID, ur.RoleID, ur.TenantID, ur.NamespaceID, ur.GrantedBy, ur.ExpiresAt, ur.CreatedAt)
return err
}
// RevokeRole revokes a role from a user
func (s *Store) RevokeRole(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM compliance_user_roles
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND (namespace_id = $4 OR (namespace_id IS NULL AND $4 IS NULL))
`, userID, roleID, tenantID, namespaceID)
return err
}
// GetUserRoles retrieves all roles for a user in a tenant
func (s *Store) GetUserRoles(ctx context.Context, userID, tenantID uuid.UUID) ([]*UserRole, error) {
rows, err := s.pool.Query(ctx, `
SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at,
r.name as role_name, r.permissions as role_permissions,
n.name as namespace_name
FROM compliance_user_roles ur
JOIN compliance_roles r ON ur.role_id = r.id
LEFT JOIN compliance_namespaces n ON ur.namespace_id = n.id
WHERE ur.user_id = $1 AND ur.tenant_id = $2
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
ORDER BY r.hierarchy_level, r.name
`, userID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var userRoles []*UserRole
for rows.Next() {
var ur UserRole
var namespaceName *string
err := rows.Scan(
&ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID,
&ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt,
&ur.RoleName, &ur.RolePermissions, &namespaceName,
)
if err != nil {
continue
}
if namespaceName != nil {
ur.NamespaceName = *namespaceName
}
userRoles = append(userRoles, &ur)
}
return userRoles, nil
}
// GetUserRolesForNamespace retrieves roles for a user in a specific namespace
func (s *Store) GetUserRolesForNamespace(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]*UserRole, error) {
rows, err := s.pool.Query(ctx, `
SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at,
r.name as role_name, r.permissions as role_permissions
FROM compliance_user_roles ur
JOIN compliance_roles r ON ur.role_id = r.id
WHERE ur.user_id = $1 AND ur.tenant_id = $2
AND (ur.namespace_id = $3 OR ur.namespace_id IS NULL)
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
ORDER BY r.hierarchy_level, r.name
`, userID, tenantID, namespaceID)
if err != nil {
return nil, err
}
defer rows.Close()
var userRoles []*UserRole
for rows.Next() {
var ur UserRole
err := rows.Scan(
&ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID,
&ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt,
&ur.RoleName, &ur.RolePermissions,
)
if err != nil {
continue
}
userRoles = append(userRoles, &ur)
}
return userRoles, nil
}
// ============================================================================
// LLM Policy Operations
// ============================================================================
// CreateLLMPolicy creates a new LLM policy
func (s *Store) CreateLLMPolicy(ctx context.Context, policy *LLMPolicy) error {
policy.ID = uuid.New()
policy.CreatedAt = time.Now().UTC()
policy.UpdatedAt = policy.CreatedAt
_, err := s.pool.Exec(ctx, `
INSERT INTO compliance_llm_policies (
id, tenant_id, namespace_id, name, description,
allowed_data_categories, blocked_data_categories,
require_pii_redaction, pii_redaction_level,
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
is_active, priority, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
`,
policy.ID, policy.TenantID, policy.NamespaceID, policy.Name, policy.Description,
policy.AllowedDataCategories, policy.BlockedDataCategories,
policy.RequirePIIRedaction, policy.PIIRedactionLevel,
policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour,
policy.IsActive, policy.Priority, policy.CreatedAt, policy.UpdatedAt,
)
return err
}
// GetLLMPolicy retrieves an LLM policy by ID
func (s *Store) GetLLMPolicy(ctx context.Context, id uuid.UUID) (*LLMPolicy, error) {
var policy LLMPolicy
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, namespace_id, name, description,
allowed_data_categories, blocked_data_categories,
require_pii_redaction, pii_redaction_level,
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
is_active, priority, created_at, updated_at
FROM compliance_llm_policies
WHERE id = $1
`, id).Scan(
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
)
return &policy, err
}
// GetEffectiveLLMPolicy retrieves the effective LLM policy for a namespace
func (s *Store) GetEffectiveLLMPolicy(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (*LLMPolicy, error) {
var policy LLMPolicy
// Get most specific active policy (namespace-specific or tenant-wide)
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, namespace_id, name, description,
allowed_data_categories, blocked_data_categories,
require_pii_redaction, pii_redaction_level,
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
is_active, priority, created_at, updated_at
FROM compliance_llm_policies
WHERE tenant_id = $1
AND is_active = TRUE
AND (namespace_id = $2 OR namespace_id IS NULL)
ORDER BY
CASE WHEN namespace_id = $2 THEN 0 ELSE 1 END,
priority ASC
LIMIT 1
`, tenantID, namespaceID).Scan(
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil // No policy = allow all
}
return &policy, err
}
// ListLLMPolicies lists LLM policies for a tenant
func (s *Store) ListLLMPolicies(ctx context.Context, tenantID uuid.UUID) ([]*LLMPolicy, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, namespace_id, name, description,
allowed_data_categories, blocked_data_categories,
require_pii_redaction, pii_redaction_level,
allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour,
is_active, priority, created_at, updated_at
FROM compliance_llm_policies
WHERE tenant_id = $1
ORDER BY priority, name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var policies []*LLMPolicy
for rows.Next() {
var policy LLMPolicy
err := rows.Scan(
&policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description,
&policy.AllowedDataCategories, &policy.BlockedDataCategories,
&policy.RequirePIIRedaction, &policy.PIIRedactionLevel,
&policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour,
&policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt,
)
if err != nil {
continue
}
policies = append(policies, &policy)
}
return policies, nil
}
// UpdateLLMPolicy updates an LLM policy
func (s *Store) UpdateLLMPolicy(ctx context.Context, policy *LLMPolicy) error {
policy.UpdatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE compliance_llm_policies SET
name = $2, description = $3,
allowed_data_categories = $4, blocked_data_categories = $5,
require_pii_redaction = $6, pii_redaction_level = $7,
allowed_models = $8, max_tokens_per_request = $9, max_requests_per_day = $10, max_requests_per_hour = $11,
is_active = $12, priority = $13, updated_at = $14
WHERE id = $1
`,
policy.ID, policy.Name, policy.Description,
policy.AllowedDataCategories, policy.BlockedDataCategories,
policy.RequirePIIRedaction, policy.PIIRedactionLevel,
policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour,
policy.IsActive, policy.Priority, policy.UpdatedAt,
)
return err
}
// DeleteLLMPolicy deletes an LLM policy
func (s *Store) DeleteLLMPolicy(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, `DELETE FROM compliance_llm_policies WHERE id = $1`, id)
return err
}