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>
460 lines
12 KiB
Go
460 lines
12 KiB
Go
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
|
|
}
|