fix: resolve merge conflicts from P0 role-based + audit trail branches

Combine role-based permissions (VerifyAccess/GetUserRole) with audit trail
(IP/user-agent context capture) in auth middleware and tenant resolver.
This commit is contained in:
m
2026-03-30 11:25:41 +02:00
parent 8e65463130
commit bfd5e354ad
6 changed files with 32 additions and 127 deletions

View File

@@ -9,19 +9,11 @@ import (
type contextKey string type contextKey string
const ( const (
<<<<<<< HEAD
userIDKey contextKey = "user_id" userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id" tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address" ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent" userAgentKey contextKey = "user_agent"
||||||| 82878df
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
=======
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
>>>>>>> mai/pike/p0-role-based
) )
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -41,7 +33,15 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID) id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok return id, ok
} }
<<<<<<< HEAD
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip) ctx = context.WithValue(ctx, ipKey, ip)
@@ -62,15 +62,3 @@ func UserAgentFromContext(ctx context.Context) *string {
} }
return nil return nil
} }
||||||| 82878df
=======
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
>>>>>>> mai/pike/p0-role-based

View File

@@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
} }
ctx := ContextWithUserID(r.Context(), userID) ctx := ContextWithUserID(r.Context(), userID)
<<<<<<< HEAD
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
||||||| 82878df
// Resolve tenant and role from user_tenants
var membership struct {
TenantID uuid.UUID `db:"tenant_id"`
Role string `db:"role"`
}
err = m.db.GetContext(r.Context(), &membership,
"SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
=======
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
// Capture IP and user-agent for audit logging // Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get("X-Forwarded-For")
@@ -73,7 +43,8 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
} }
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
>>>>>>> mai/knuth/p0-audit-trail-append // Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@@ -12,12 +12,8 @@ import (
// Defined as an interface to avoid circular dependency with services. // Defined as an interface to avoid circular dependency with services.
type TenantLookup interface { type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
<<<<<<< HEAD
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
||||||| 82878df
=======
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
>>>>>>> mai/pike/p0-role-based
} }
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -46,35 +42,20 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return return
} }
<<<<<<< HEAD
// Verify user has access to this tenant // Verify user has access and get their role
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil { if err != nil {
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed) slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
if !hasAccess { if role == "" {
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden) http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return return
} }
||||||| 82878df
=======
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError)
return
}
if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden)
return
}
>>>>>>> mai/pike/p0-role-based
tenantID = parsed tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role)) r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else { } else {
// Default to user's first tenant (role already set by middleware) // Default to user's first tenant (role already set by middleware)

View File

@@ -10,49 +10,35 @@ import (
) )
type mockTenantLookup struct { type mockTenantLookup struct {
<<<<<<< HEAD
tenantID *uuid.UUID tenantID *uuid.UUID
err error err error
hasAccess bool hasAccess bool
accessErr error accessErr error
||||||| 82878df role string
tenantID *uuid.UUID noAccess bool // when true, GetUserRole returns ""
err error
=======
tenantID *uuid.UUID
role string
err error
>>>>>>> mai/pike/p0-role-based
} }
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err return m.tenantID, m.err
} }
<<<<<<< HEAD
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr return m.hasAccess, m.accessErr
} }
||||||| 82878df
=======
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.noAccess {
return "", m.err
}
if m.role != "" { if m.role != "" {
return m.role, m.err return m.role, m.err
} }
return "associate", m.err return "associate", m.err
} }
>>>>>>> mai/pike/p0-role-based
func TestTenantResolver_FromHeader(t *testing.T) { func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
<<<<<<< HEAD tr := NewTenantResolver(&mockTenantLookup{role: "partner", hasAccess: true})
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
||||||| 82878df
tr := NewTenantResolver(&mockTenantLookup{})
=======
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
>>>>>>> mai/pike/p0-role-based
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -81,7 +67,7 @@ func TestTenantResolver_FromHeader(t *testing.T) {
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) { func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false}) tr := NewTenantResolver(&mockTenantLookup{noAccess: true})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called") t.Fatal("next should not be called")

View File

@@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
<<<<<<< HEAD if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil {
if err := h.deadlines.Delete(tenantID, deadlineID); err != nil {
writeError(w, http.StatusNotFound, "deadline not found") writeError(w, http.StatusNotFound, "deadline not found")
||||||| 82878df
err = h.deadlines.Delete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
=======
err = h.deadlines.Delete(r.Context(), tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
>>>>>>> mai/knuth/p0-audit-trail-append
return return
} }

View File

@@ -15,7 +15,7 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
) )
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService) http.Handler { func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService, youpcDB ...*sqlx.DB) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
// Services // Services
@@ -29,19 +29,17 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineRuleSvc := services.NewDeadlineRuleService(db) deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc) calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
<<<<<<< HEAD
documentSvc := services.NewDocumentService(db, storageCli, auditSvc) documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
||||||| 82878df
documentSvc := services.NewDocumentService(db, storageCli)
=======
documentSvc := services.NewDocumentService(db, storageCli)
assignmentSvc := services.NewCaseAssignmentService(db) assignmentSvc := services.NewCaseAssignmentService(db)
>>>>>>> mai/pike/p0-role-based
// AI service (optional — only if API key is configured) // AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" { if cfg.AnthropicAPIKey != "" {
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db) var ydb *sqlx.DB
if len(youpcDB) > 0 {
ydb = youpcDB[0]
}
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db, ydb)
aiH = handlers.NewAIHandler(aiSvc) aiH = handlers.NewAIHandler(aiSvc)
} }
@@ -162,16 +160,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
// Dashboard — all can view // Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
<<<<<<< HEAD
// Audit log // Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List) scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents
||||||| 82878df
// Documents
=======
// Documents — all can upload, delete checked in handler (own vs all) // Documents — all can upload, delete checked in handler (own vs all)
>>>>>>> mai/pike/p0-role-based
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
@@ -183,9 +175,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10) aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines))) scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
scoped.HandleFunc("POST /api/ai/draft-document", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.DraftDocument)))
scoped.HandleFunc("POST /api/ai/case-strategy", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.CaseStrategy)))
scoped.HandleFunc("POST /api/ai/similar-cases", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SimilarCases)))
} }
<<<<<<< HEAD
// Notifications // Notifications
if notifH != nil { if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List) scoped.HandleFunc("GET /api/notifications", notifH.List)
@@ -196,12 +190,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
} }
// CalDAV sync endpoints
||||||| 82878df
// CalDAV sync endpoints
=======
// CalDAV sync endpoints — settings permission required // CalDAV sync endpoints — settings permission required
>>>>>>> mai/pike/p0-role-based
if calDAVSvc != nil { if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc) calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))