fix: resolve merge conflicts from role-based permissions + audit trail branches
Combines auth context keys (user role, IP, user-agent), tenant resolver (GetUserRole-based access verification), middleware (deferred tenant resolution + request info capture), and router (audit log + notifications + assignments).
This commit is contained in:
@@ -9,19 +9,11 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
<<<<<<< HEAD
|
||||
userIDKey contextKey = "user_id"
|
||||
tenantIDKey contextKey = "tenant_id"
|
||||
ipKey contextKey = "ip_address"
|
||||
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
|
||||
ipKey contextKey = "ip_address"
|
||||
userAgentKey contextKey = "user_agent"
|
||||
)
|
||||
|
||||
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)
|
||||
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 {
|
||||
ctx = context.WithValue(ctx, ipKey, ip)
|
||||
@@ -62,15 +62,3 @@ func UserAgentFromContext(ctx context.Context) *string {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
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
|
||||
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())
|
||||
|
||||
>>>>>>> mai/knuth/p0-audit-trail-append
|
||||
// Tenant/role resolution is handled by TenantResolver middleware for scoped routes.
|
||||
// Tenant management routes handle their own access control.
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,12 +12,7 @@ import (
|
||||
// Defined as an interface to avoid circular dependency with services.
|
||||
type TenantLookup interface {
|
||||
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
|
||||
<<<<<<< HEAD
|
||||
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
|
||||
||||||| 82878df
|
||||
=======
|
||||
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
|
||||
@@ -46,38 +41,22 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
||||
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
// Verify user has access to this tenant
|
||||
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
|
||||
// Verify user has access and get their role
|
||||
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
|
||||
if err != nil {
|
||||
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasAccess {
|
||||
if role == "" {
|
||||
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
|
||||
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
|
||||
// Override the role from middleware with the correct one for this tenant
|
||||
r = r.WithContext(ContextWithUserRole(r.Context(), role))
|
||||
} else {
|
||||
// Default to user's first tenant (role already set by middleware)
|
||||
// Default to user's first tenant
|
||||
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
|
||||
@@ -89,6 +68,15 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
tenantID = *first
|
||||
|
||||
// Resolve role for default tenant
|
||||
role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve role for default tenant", "error", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(ContextWithUserRole(r.Context(), role))
|
||||
}
|
||||
|
||||
ctx := ContextWithTenantID(r.Context(), tenantID)
|
||||
|
||||
@@ -10,49 +10,26 @@ import (
|
||||
)
|
||||
|
||||
type mockTenantLookup struct {
|
||||
<<<<<<< HEAD
|
||||
tenantID *uuid.UUID
|
||||
err error
|
||||
hasAccess bool
|
||||
accessErr error
|
||||
||||||| 82878df
|
||||
tenantID *uuid.UUID
|
||||
err error
|
||||
=======
|
||||
tenantID *uuid.UUID
|
||||
role string
|
||||
roleSet bool // true means role was explicitly set (even if empty)
|
||||
err error
|
||||
>>>>>>> mai/pike/p0-role-based
|
||||
}
|
||||
|
||||
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
||||
return m.tenantID, m.err
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
|
||||
return m.hasAccess, m.accessErr
|
||||
}
|
||||
|
||||
||||||| 82878df
|
||||
=======
|
||||
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
|
||||
if m.role != "" {
|
||||
if m.roleSet {
|
||||
return m.role, m.err
|
||||
}
|
||||
return "associate", m.err
|
||||
}
|
||||
|
||||
>>>>>>> mai/pike/p0-role-based
|
||||
func TestTenantResolver_FromHeader(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
<<<<<<< HEAD
|
||||
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
|
||||
||||||| 82878df
|
||||
tr := NewTenantResolver(&mockTenantLookup{})
|
||||
=======
|
||||
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
|
||||
>>>>>>> mai/pike/p0-role-based
|
||||
|
||||
var gotTenantID uuid.UUID
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -81,7 +58,7 @@ func TestTenantResolver_FromHeader(t *testing.T) {
|
||||
|
||||
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
|
||||
tr := NewTenantResolver(&mockTenantLookup{role: "", roleSet: true})
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next should not be called")
|
||||
@@ -101,7 +78,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
|
||||
|
||||
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
|
||||
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"})
|
||||
|
||||
var gotTenantID uuid.UUID
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -198,18 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
if err := h.deadlines.Delete(tenantID, deadlineID); err != nil {
|
||||
if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
||||
calculator := services.NewDeadlineCalculator(holidaySvc)
|
||||
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
|
||||
<<<<<<< HEAD
|
||||
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
|
||||
||||||| 82878df
|
||||
documentSvc := services.NewDocumentService(db, storageCli)
|
||||
=======
|
||||
documentSvc := services.NewDocumentService(db, storageCli)
|
||||
assignmentSvc := services.NewCaseAssignmentService(db)
|
||||
>>>>>>> mai/pike/p0-role-based
|
||||
|
||||
// AI service (optional — only if API key is configured)
|
||||
var aiH *handlers.AIHandler
|
||||
@@ -79,6 +73,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
api := http.NewServeMux()
|
||||
|
||||
// Tenant management (no tenant resolver — these operate across tenants)
|
||||
api.HandleFunc("POST /api/tenants/auto-assign", tenantH.AutoAssign)
|
||||
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
|
||||
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
|
||||
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
|
||||
@@ -162,16 +157,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
// Dashboard — all can view
|
||||
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Audit log
|
||||
scoped.HandleFunc("GET /api/audit-log", auditH.List)
|
||||
scoped.HandleFunc("GET /api/audit-log", perm(auth.PermViewAuditLog, auditH.List))
|
||||
|
||||
// Documents
|
||||
||||||| 82878df
|
||||
// Documents
|
||||
=======
|
||||
// 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("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
|
||||
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
|
||||
@@ -185,7 +174,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Notifications
|
||||
if notifH != nil {
|
||||
scoped.HandleFunc("GET /api/notifications", notifH.List)
|
||||
@@ -196,12 +184,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
|
||||
}
|
||||
|
||||
// CalDAV sync endpoints
|
||||
||||||| 82878df
|
||||
// CalDAV sync endpoints
|
||||
=======
|
||||
// CalDAV sync endpoints — settings permission required
|
||||
>>>>>>> mai/pike/p0-role-based
|
||||
if calDAVSvc != nil {
|
||||
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
|
||||
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))
|
||||
|
||||
Reference in New Issue
Block a user