Files
pulse/internal/api/access_control_handlers.go
T
2026-08-09 13:36:07 +01:00

617 lines
20 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// validRoleID matches alphanumeric IDs with hyphens and underscores (1-64 chars)
var validRoleID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// validUsername accepts local identities plus the colon-delimited opaque SSO
// principals emitted by stableSSOPrincipal. Slashes and path traversal
// characters remain excluded.
var validUsername = regexp.MustCompile(`^[a-zA-Z0-9._@+:-]{1,256}$`)
// RBACHandlers provides HTTP handlers for RBAC management.
type RBACHandlers struct {
config *config.Config
rbacProvider *TenantRBACProvider
}
// NewRBACHandlers creates a new RBACHandlers instance.
func NewRBACHandlers(cfg *config.Config, rbacProvider ...*TenantRBACProvider) *RBACHandlers {
var provider *TenantRBACProvider
if len(rbacProvider) > 0 {
provider = rbacProvider[0]
}
return &RBACHandlers{
config: cfg,
rbacProvider: provider,
}
}
// rbacMigrationFailure reports a store whose legacy import was rejected.
//
// The store itself stays live so admin recovery keeps working, but RBAC
// management routes must still fail closed: the operator is looking at data
// that does not include their legacy roles and assignments, and editing it
// would build on an incomplete picture. Recovery reaches the provider directly
// and so is deliberately not routed through this check.
func rbacMigrationFailure(manager any) error {
degraded, ok := manager.(interface{ MigrationError() error })
if !ok {
return nil
}
return degraded.MigrationError()
}
// getManager returns the RBAC Manager for the org in the request context.
// Falls back to global manager if no provider is set (backward compat).
func (h *RBACHandlers) getManager(ctx context.Context) (auth.Manager, error) {
if h.rbacProvider != nil {
orgID := GetOrgID(ctx)
manager, err := h.rbacProvider.GetManager(orgID)
if err != nil {
return nil, err
}
if err := rbacMigrationFailure(manager); err != nil {
return nil, err
}
return manager, nil
}
return auth.GetManager(), nil
}
// getExtendedManager returns the ExtendedManager for the org in the request context.
func (h *RBACHandlers) getExtendedManager(ctx context.Context) (auth.ExtendedManager, error) {
if h.rbacProvider != nil {
orgID := GetOrgID(ctx)
manager, err := h.rbacProvider.GetManager(orgID)
if err != nil {
return nil, err
}
if err := rbacMigrationFailure(manager); err != nil {
return nil, err
}
return manager, nil
}
return auth.GetExtendedManager(), nil
}
func writeRBACStoreError(w http.ResponseWriter, err error) {
log.Error().Err(err).Msg("RBAC store is unavailable")
writeErrorResponse(
w,
http.StatusServiceUnavailable,
"rbac_store_unavailable",
"RBAC data could not be loaded; check the server logs before retrying",
nil,
)
}
// HandleRoles handles list, create, update, and delete actions for roles.
func (h *RBACHandlers) HandleRoles(w http.ResponseWriter, r *http.Request) {
manager, err := h.getManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if manager == nil {
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
return
}
roleID := strings.TrimPrefix(r.URL.Path, "/api/admin/roles")
roleID = strings.TrimPrefix(roleID, "/")
roleID = strings.TrimSuffix(roleID, "/")
// Validate role ID format if provided
if roleID != "" && !validRoleID.MatchString(roleID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
return
}
switch r.Method {
case http.MethodGet:
if roleID == "" {
// List all roles
var roles []auth.Role
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
roles, err = errorAware.GetRolesWithError()
if err != nil {
writeRBACStoreError(w, err)
return
}
} else {
roles = manager.GetRoles()
}
if roles == nil {
roles = []auth.Role{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(roles)
} else {
// Get specific role
role, ok := manager.GetRole(roleID)
if !ok {
writeErrorResponse(w, http.StatusNotFound, "not_found", "Role not found", nil)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(role)
}
case http.MethodPost:
if roleID != "" {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "POST is for creating roles; do not include an ID in the path", nil)
return
}
// Limit request body size
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
var role auth.Role
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid role data", nil)
return
}
// Validate role ID in body
if role.ID != "" && !validRoleID.MatchString(role.ID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
return
}
if err := manager.SaveRole(role); err != nil {
LogAuditEventForTenant(GetOrgID(r.Context()), "role_create_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to create role %s", role.ID))
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save role", nil)
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "role_created", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Created role "+role.ID)
RecordRBACRoleMutation("create")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(role)
case http.MethodPut:
// Limit request body size
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
var role auth.Role
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid role data", nil)
return
}
if roleID != "" {
role.ID = roleID
}
// Validate role ID
if role.ID != "" && !validRoleID.MatchString(role.ID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
return
}
if err := manager.SaveRole(role); err != nil {
LogAuditEventForTenant(GetOrgID(r.Context()), "role_update_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to update role %s", role.ID))
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save role", nil)
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "role_updated", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Updated role "+role.ID)
RecordRBACRoleMutation("update")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(role)
case http.MethodDelete:
if roleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Role ID is required", nil)
return
}
if err := manager.DeleteRole(roleID); err != nil {
LogAuditEventForTenant(GetOrgID(r.Context()), "role_delete_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to delete role %s", roleID))
writeErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete role", nil)
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "role_deleted", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Deleted role "+roleID)
RecordRBACRoleMutation("delete")
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleGetUsers lists users with their assigned roles.
func (h *RBACHandlers) HandleGetUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
manager, err := h.getManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if manager == nil {
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
return
}
var assignments []auth.UserRoleAssignment
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
assignments, err = errorAware.GetUserAssignmentsWithError()
if err != nil {
writeRBACStoreError(w, err)
return
}
} else {
assignments = manager.GetUserAssignments()
}
if assignments == nil {
assignments = []auth.UserRoleAssignment{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(assignments)
}
// HandleUserRoleActions handles assigning/updating roles for a user.
func (h *RBACHandlers) HandleUserRoleActions(w http.ResponseWriter, r *http.Request) {
manager, err := h.getManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if manager == nil {
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
return
}
// Extract username from path: /api/admin/users/{username}/roles
path := strings.TrimPrefix(r.URL.Path, "/api/admin/users/")
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 1 || parts[0] == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_username", "Username is required", nil)
return
}
username := parts[0]
// Validate username format to prevent injection
if !validUsername.MatchString(username) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_username", "Invalid username format", nil)
return
}
switch r.Method {
case http.MethodDelete:
if len(parts) != 1 {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
callerUser := auth.GetUser(r.Context())
if callerUser != "" && strings.EqualFold(callerUser, username) {
LogAuditEventForTenant(GetOrgID(r.Context()), "user_deprovision_self_denied", callerUser, GetClientIP(r), r.URL.Path, false, "Attempted to deprovision own identity")
writeErrorResponse(w, http.StatusForbidden, "self_deprovision_denied", "Cannot remove your own user access", nil)
return
}
contextual, supportsContextualDelete := manager.(auth.ContextualUserDeprovisioner)
deprovisioner, supportsDelete := manager.(auth.UserDeprovisioner)
if !supportsContextualDelete && !supportsDelete {
writeErrorResponse(w, http.StatusNotImplemented, "deprovision_unavailable", "User removal is not available for this RBAC store", nil)
return
}
var exists bool
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
_, exists, err = errorAware.GetUserAssignmentWithError(username)
} else {
_, exists = manager.GetUserAssignment(username)
}
if err != nil {
writeRBACStoreError(w, err)
return
}
if !exists {
writeErrorResponse(w, http.StatusNotFound, "not_found", "User assignment not found", nil)
return
}
// Revoke first so a storage failure can never leave an authenticated
// session alive after its RBAC identity has disappeared. If the delete
// then fails, the retained identity can safely establish a new session.
InvalidateUserSessions(username)
var deleted bool
if supportsContextualDelete {
deleted, err = contextual.DeleteUserWithContext(username, callerUser)
} else {
deleted, err = deprovisioner.DeleteUser(username)
}
if err != nil {
LogAuditEventForTenant(GetOrgID(r.Context()), "user_deprovision_failed", callerUser, GetClientIP(r), r.URL.Path, false, "Revoked sessions but failed to remove user "+username)
writeRBACStoreError(w, err)
return
}
if !deleted {
writeErrorResponse(w, http.StatusNotFound, "not_found", "User assignment not found", nil)
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "user_deprovisioned", callerUser, GetClientIP(r), r.URL.Path, true, "Removed user "+username+" and revoked active sessions")
RecordRBACRoleMutation("deprovision")
w.WriteHeader(http.StatusNoContent)
case http.MethodPut, http.MethodPost:
// Self-escalation prevention: users cannot modify their own role assignments.
// This prevents privilege escalation and ensures audit trail integrity.
// Only blocks writes — reads (GET) on own roles/permissions are allowed.
callerUser := auth.GetUser(r.Context())
if callerUser != "" && strings.EqualFold(callerUser, username) {
LogAuditEventForTenant(GetOrgID(r.Context()), "user_roles_self_modify_denied", callerUser, GetClientIP(r), r.URL.Path, false, "Attempted to modify own role assignments")
writeErrorResponse(w, http.StatusForbidden, "self_modification_denied", "Cannot modify your own role assignments", nil)
return
}
// Limit request body size
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
var req struct {
RoleIDs []string `json:"roleIds"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body", nil)
return
}
// Validate all role IDs
for _, roleID := range req.RoleIDs {
if !validRoleID.MatchString(roleID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", fmt.Sprintf("Invalid role ID format: %s", roleID), nil)
return
}
}
if err := manager.UpdateUserRoles(username, req.RoleIDs); err != nil {
LogAuditEventForTenant(GetOrgID(r.Context()), "user_roles_update_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to update roles for user %s", username))
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update user roles", nil)
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "user_roles_updated", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Updated roles for user "+username+": ["+strings.Join(req.RoleIDs, ", ")+"]")
RecordRBACRoleMutation("assign")
w.WriteHeader(http.StatusNoContent)
case http.MethodGet:
// Get effective permissions
if len(parts) > 1 && parts[1] == "permissions" {
var perms []auth.Permission
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
perms, err = errorAware.GetUserPermissionsWithError(username)
if err != nil {
writeRBACStoreError(w, err)
return
}
} else {
perms = manager.GetUserPermissions(username)
}
if perms == nil {
perms = []auth.Permission{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(perms)
return
}
// Get specific assignment
var assignment auth.UserRoleAssignment
var ok bool
if errorAware, supportsErrors := manager.(auth.ErrorAwareManager); supportsErrors {
assignment, ok, err = errorAware.GetUserAssignmentWithError(username)
if err != nil {
writeRBACStoreError(w, err)
return
}
} else {
assignment, ok = manager.GetUserAssignment(username)
}
if !ok {
writeErrorResponse(w, http.StatusNotFound, "not_found", "User assignment not found", nil)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(assignment)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleRBACChangelog returns the RBAC change history.
func (h *RBACHandlers) HandleRBACChangelog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
em, err := h.getExtendedManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if em == nil {
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC changelog is not available (requires Pro)", nil)
return
}
// Parse query parameters
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if _, err := fmt.Sscanf(l, "%d", &limit); err == nil {
if limit <= 0 || limit > 1000 {
limit = 100
}
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if _, err := fmt.Sscanf(o, "%d", &offset); err == nil {
if offset < 0 {
offset = 0
}
}
}
// Filter by entity if provided
entityType := r.URL.Query().Get("entity_type")
entityID := r.URL.Query().Get("entity_id")
var logs []auth.RBACChangeLog
if entityType != "" && entityID != "" {
logs = em.GetChangeLogsForEntity(entityType, entityID)
} else {
logs = em.GetChangeLogs(limit, offset)
}
// Return empty array instead of null
if logs == nil {
logs = []auth.RBACChangeLog{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logs)
}
// HandleRoleEffective returns a role with all inherited permissions.
func (h *RBACHandlers) HandleRoleEffective(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
em, err := h.getExtendedManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if em == nil {
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "Role inheritance is not available (requires Pro)", nil)
return
}
// Extract role ID from path: /api/admin/roles/{id}/effective
path := strings.TrimPrefix(r.URL.Path, "/api/admin/roles/")
path = strings.TrimSuffix(path, "/effective")
roleID := strings.TrimSuffix(path, "/")
if roleID == "" || !validRoleID.MatchString(roleID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
return
}
role, effectivePerms, ok := em.GetRoleWithInheritance(roleID)
if !ok {
writeErrorResponse(w, http.StatusNotFound, "not_found", "Role not found", nil)
return
}
// Return role with effective permissions
response := struct {
auth.Role
EffectivePermissions []auth.Permission `json:"effectivePermissions"`
}{
Role: role,
EffectivePermissions: effectivePerms,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleUserEffectivePermissions returns a user's effective permissions with inheritance.
func (h *RBACHandlers) HandleUserEffectivePermissions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
manager, err := h.getManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if manager == nil {
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
return
}
// Extract username from path: /api/admin/users/{username}/effective-permissions
path := strings.TrimPrefix(r.URL.Path, "/api/admin/users/")
path = strings.TrimSuffix(path, "/effective-permissions")
username := strings.TrimSuffix(path, "/")
if username == "" || !validUsername.MatchString(username) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_username", "Invalid username format", nil)
return
}
// Check if we have extended manager for inheritance
em, err := h.getExtendedManager(r.Context())
if err != nil {
writeRBACStoreError(w, err)
return
}
if em != nil {
roles := em.GetRolesWithInheritance(username)
// Collect all effective permissions
permMap := make(map[string]auth.Permission)
for _, role := range roles {
for _, perm := range role.Permissions {
key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect()
permMap[key] = perm
}
}
var perms []auth.Permission
for _, perm := range permMap {
perms = append(perms, perm)
}
response := struct {
Username string `json:"username"`
Roles []auth.Role `json:"roles"`
Permissions []auth.Permission `json:"permissions"`
}{
Username: username,
Roles: roles,
Permissions: perms,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
// Fall back to basic permissions
perms := manager.GetUserPermissions(username)
if perms == nil {
perms = []auth.Permission{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(perms)
}