feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0)
This commit is contained in:
@@ -9,10 +9,19 @@ 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
|
||||
)
|
||||
|
||||
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
|
||||
@@ -32,6 +41,7 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
|
||||
ctx = context.WithValue(ctx, ipKey, ip)
|
||||
@@ -52,3 +62,15 @@ 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
|
||||
|
||||
@@ -40,15 +40,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
||||
// Tenant management routes handle their own access control.
|
||||
||||||| 82878df
|
||||
|
||||
// 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)
|
||||
// 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, tenantID)
|
||||
ctx = ContextWithTenantID(ctx, membership.TenantID)
|
||||
ctx = ContextWithUserRole(ctx, membership.Role)
|
||||
|
||||
=======
|
||||
|
||||
|
||||
213
backend/internal/auth/permissions.go
Normal file
213
backend/internal/auth/permissions.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Valid roles ordered by privilege level (highest first).
|
||||
var ValidRoles = []string{"owner", "partner", "associate", "paralegal", "secretary"}
|
||||
|
||||
// IsValidRole checks if a role string is one of the defined roles.
|
||||
func IsValidRole(role string) bool {
|
||||
for _, r := range ValidRoles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Permission represents an action that can be checked against roles.
|
||||
type Permission int
|
||||
|
||||
const (
|
||||
PermManageTeam Permission = iota
|
||||
PermManageBilling
|
||||
PermCreateCase
|
||||
PermEditAllCases
|
||||
PermEditAssignedCase
|
||||
PermViewAllCases
|
||||
PermManageDeadlines
|
||||
PermManageAppointments
|
||||
PermUploadDocuments
|
||||
PermDeleteDocuments
|
||||
PermDeleteOwnDocuments
|
||||
PermViewAuditLog
|
||||
PermManageSettings
|
||||
PermAIExtraction
|
||||
)
|
||||
|
||||
// rolePermissions maps each role to its set of permissions.
|
||||
var rolePermissions = map[string]map[Permission]bool{
|
||||
"owner": {
|
||||
PermManageTeam: true,
|
||||
PermManageBilling: true,
|
||||
PermCreateCase: true,
|
||||
PermEditAllCases: true,
|
||||
PermEditAssignedCase: true,
|
||||
PermViewAllCases: true,
|
||||
PermManageDeadlines: true,
|
||||
PermManageAppointments: true,
|
||||
PermUploadDocuments: true,
|
||||
PermDeleteDocuments: true,
|
||||
PermDeleteOwnDocuments: true,
|
||||
PermViewAuditLog: true,
|
||||
PermManageSettings: true,
|
||||
PermAIExtraction: true,
|
||||
},
|
||||
"partner": {
|
||||
PermManageTeam: true,
|
||||
PermManageBilling: true,
|
||||
PermCreateCase: true,
|
||||
PermEditAllCases: true,
|
||||
PermEditAssignedCase: true,
|
||||
PermViewAllCases: true,
|
||||
PermManageDeadlines: true,
|
||||
PermManageAppointments: true,
|
||||
PermUploadDocuments: true,
|
||||
PermDeleteDocuments: true,
|
||||
PermDeleteOwnDocuments: true,
|
||||
PermViewAuditLog: true,
|
||||
PermManageSettings: true,
|
||||
PermAIExtraction: true,
|
||||
},
|
||||
"associate": {
|
||||
PermCreateCase: true,
|
||||
PermEditAssignedCase: true,
|
||||
PermViewAllCases: true,
|
||||
PermManageDeadlines: true,
|
||||
PermManageAppointments: true,
|
||||
PermUploadDocuments: true,
|
||||
PermDeleteOwnDocuments: true,
|
||||
PermAIExtraction: true,
|
||||
},
|
||||
"paralegal": {
|
||||
PermEditAssignedCase: true,
|
||||
PermViewAllCases: true,
|
||||
PermManageDeadlines: true,
|
||||
PermManageAppointments: true,
|
||||
PermUploadDocuments: true,
|
||||
},
|
||||
"secretary": {
|
||||
PermViewAllCases: true,
|
||||
PermManageAppointments: true,
|
||||
PermUploadDocuments: true,
|
||||
},
|
||||
}
|
||||
|
||||
// HasPermission checks if the given role has the specified permission.
|
||||
func HasPermission(role string, perm Permission) bool {
|
||||
perms, ok := rolePermissions[role]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return perms[perm]
|
||||
}
|
||||
|
||||
// RequirePermission returns middleware that checks if the user's role has the given permission.
|
||||
func RequirePermission(perm Permission) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role := UserRoleFromContext(r.Context())
|
||||
if role == "" || !HasPermission(role, perm) {
|
||||
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole returns middleware that checks if the user has one of the specified roles.
|
||||
func RequireRole(roles ...string) func(http.Handler) http.Handler {
|
||||
allowed := make(map[string]bool, len(roles))
|
||||
for _, r := range roles {
|
||||
allowed[r] = true
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role := UserRoleFromContext(r.Context())
|
||||
if !allowed[role] {
|
||||
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// IsAssignedToCase checks if a user is assigned to a specific case.
|
||||
func IsAssignedToCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS(SELECT 1 FROM case_assignments WHERE user_id = $1 AND case_id = $2)`,
|
||||
userID, caseID)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// CanEditCase checks if a user can edit a specific case based on role and assignment.
|
||||
func CanEditCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID, role string) (bool, error) {
|
||||
// Owner and partner can edit all cases
|
||||
if HasPermission(role, PermEditAllCases) {
|
||||
return true, nil
|
||||
}
|
||||
// Others need to be assigned
|
||||
if !HasPermission(role, PermEditAssignedCase) {
|
||||
return false, nil
|
||||
}
|
||||
return IsAssignedToCase(ctx, db, userID, caseID)
|
||||
}
|
||||
|
||||
// CanDeleteDocument checks if a user can delete a specific document.
|
||||
func CanDeleteDocument(role string, docUploaderID, userID uuid.UUID) bool {
|
||||
if HasPermission(role, PermDeleteDocuments) {
|
||||
return true
|
||||
}
|
||||
if HasPermission(role, PermDeleteOwnDocuments) {
|
||||
return docUploaderID == userID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// permissionNames maps Permission constants to their string names for frontend use.
|
||||
var permissionNames = map[Permission]string{
|
||||
PermManageTeam: "manage_team",
|
||||
PermManageBilling: "manage_billing",
|
||||
PermCreateCase: "create_case",
|
||||
PermEditAllCases: "edit_all_cases",
|
||||
PermEditAssignedCase: "edit_assigned_case",
|
||||
PermViewAllCases: "view_all_cases",
|
||||
PermManageDeadlines: "manage_deadlines",
|
||||
PermManageAppointments: "manage_appointments",
|
||||
PermUploadDocuments: "upload_documents",
|
||||
PermDeleteDocuments: "delete_documents",
|
||||
PermDeleteOwnDocuments: "delete_own_documents",
|
||||
PermViewAuditLog: "view_audit_log",
|
||||
PermManageSettings: "manage_settings",
|
||||
PermAIExtraction: "ai_extraction",
|
||||
}
|
||||
|
||||
// GetRolePermissions returns a list of permission name strings for the given role.
|
||||
func GetRolePermissions(role string) []string {
|
||||
perms, ok := rolePermissions[role]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for p := range perms {
|
||||
if name, ok := permissionNames[p]; ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, msg string, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
}
|
||||
@@ -12,7 +12,12 @@ 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
|
||||
@@ -41,6 +46,7 @@ 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)
|
||||
@@ -54,9 +60,24 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
|
||||
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
|
||||
// Default to user's first tenant (role already set by middleware)
|
||||
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
|
||||
|
||||
@@ -10,23 +10,49 @@ 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
|
||||
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 != "" {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user