ARCHIVED: This plan was not delivered. Preserved for context.
Permissions-Based Access Control Implementation¶
Overview¶
Implement fine-grained, permission-based access control using a Hybrid Approach (Defense in Depth):
- Route-Level Authorization: Coarse-grained "Guard at the Gate"
- Service-Level Authorization: Fine-grained "Guard at the Vault" with row-level security
- Frontend Permissions: UX-driven display logic (backend-driven, not security)
Phase 1: Backend Permission Infrastructure¶
1.1 Create Permission Dependency (OPA Pattern)¶
Update policy module (backend/app/core/policy.py)
- Extend
query_policy()function to support permission checks - Add routing for
"authz/permission/check"policy path - Create
_evaluate_permission_policy()helper function - Use EXISTING
has_permission()utility fromapp.utils.permissions - Return policy decision dict:
{"allow": bool, "reason": str}
Add permission dependency (backend/app/core/security.py)
- Create dependency factory function
require_permission(path: str, action: str = "view") - Returns a FastAPI dependency that checks permissions using OPA pattern
- Follows the same pattern as
resource_service.py:- Build OPA input with user context:
{"user": {...}, "path": path, "action": action} - Query policy:
decision = await query_policy("authz/permission/check", input_data) - Check decision:
if not decision.get("allow"): raise HTTPException(403) - Return authenticated user if permission granted
- Build OPA input with user context:
- Usage:
user: User = Depends(require_permission("modules.headcount", "edit")) - Log permission checks for audit trail
Implementation details:
- Create
_build_permission_input(user, path, action)helper - Similar to
_build_opa_input()inresource_service.py - Include user ID, email, roles, and permissions
- Include logging similar to resource_service pattern
Security Considerations:
- 404 vs 403 responses: Return
404 Not Foundfor resources the user doesn't own (not403 Forbidden) to prevent enumeration attacks
1.2 Implement Data Filtering Service (OPA Pattern)¶
Extend policy module (backend/app/core/policy.py)
- Add data filtering policy evaluation
- Create
_evaluate_data_filter_policy()function - Support policies:
"authz/data/list","authz/data/access" - Return filter criteria based on user's role scope
- Return
{"allow": bool, "filters": {"unit_ids": [...], "user_id": ...}}
Create service layer with context injection pattern
Add to backend/app/services/authorization_service.py (new file) Option B: Integrate directly into domain services (e.g., headcount_service.py) =======
Stashed changes
Pattern: Context-Injected Services
class HeadcountService:
def __init__(self, db: AsyncSession, user: User):
self.db = db
self.user = user # The "Actor"
async def list_headcounts(self, skip: int = 0, limit: int = 100):
# Build OPA input using injected user context
input_data = {
"user": {"id": self.user.id, "roles": self.user.roles},
"resource_type": "headcount",
"action": "list"
}
# Query policy for data filters
decision = await query_policy("authz/data/list", input_data)
filters = decision.get("filters", {})
# Pass filters to repository
return await headcount_repo.get_headcounts(self.db, filters=filters, skip=skip, limit=limit)
Route integration:
@router.get("/headcounts")
async def list_headcounts(
user: User = Depends(require_permission("modules.headcount", "view")),
db: AsyncSession = Depends(get_db)
):
service = HeadcountService(db, user=user)
return await service.list_headcounts()
Key helpers to implement:
-
_build_data_filter_input(user: User, resource_type: str, action: str) -> dict -
_build_resource_access_input(user: User, resource_type: str, resource: dict) -> dict
1.3 Update Repository Layer (Filter-Based)¶
Update repositories to accept filter dictionaries
-
backend/app/repositories/headcount_repo.py - Update list methods to accept optional
filters: dictparameter - Apply filters to query:
if "unit_ids" in filters: query = query.where(Headcount.unit_id.in_(filters["unit_ids"])) -
Apply user filter:
if "user_id" in filters: query = query.where(Headcount.created_by == filters["user_id"]) -
backend/app/repositories/professional_travel_repo.py - Update to accept
filters: dictparameter - Apply unit filter and user filter
-
Keep existing location search, sorting, pagination logic
-
Other module repositories
- Update to accept
filters: dictparameter - Apply standard filter patterns
Filter dictionary structure:
filters = {
"unit_ids": ["ENAC", "STI"], # Empty list = no filter
"user_id": "user-123", # None = no filter
"scope": "global" | "unit" | "own" # For logging/debugging
}
1.4 Update /auth/me Endpoint¶
File: backend/app/api/v1/auth.py
- Update
/auth/meendpoint to return calculated permissions
from app.utils.permissions import calculate_user_permissions
@router.get("/me")
async def me(user: User = Depends(get_current_active_user)):
return {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"roles_raw": user.roles_raw,
"permissions": calculate_user_permissions(user.roles) # ADD THIS
}
Phase 2: Backend Route Protection¶
2.1 Update Backoffice Routes¶
File: backend/app/api/v1/backoffice.py
-
GET /backoffice/users→require_permission("backoffice.users", "view") -
POST /backoffice/users→require_permission("backoffice.users", "edit") -
PUT /backoffice/users/{id}→require_permission("backoffice.users", "edit") -
DELETE /backoffice/users/{id}→require_permission("backoffice.users", "edit") -
POST /backoffice/users/export→require_permission("backoffice.users", "export") -
Create or update UserService with context injection
- Implement policy-based filtering in
list_users() - Return 404 (not 403) in
get_user()if user lacks access
2.2 Update Module Routes¶
File: backend/app/api/v1/headcounts.py
- Add route-level guards:
- GET →
require_permission("modules.headcount", "view") -
POST/PUT/DELETE →
require_permission("modules.headcount", "edit") -
Create
HeadcountServicewith context injection - Update routes to use service with policy-based filtering
File: backend/app/api/v1/modules.py
- Professional Travel →
require_permission("modules.professional_travel", "view/edit") - Equipment →
require_permission("modules.equipment", "view/edit") - Infrastructure →
require_permission("modules.infrastructure", "view/edit") - Purchase →
require_permission("modules.purchase", "view/edit") - Internal Services →
require_permission("modules.internal_services", "view/edit") - External Cloud →
require_permission("modules.external_cloud", "view/edit")
Special case for Professional Travel:
- Policy should return
{"filters": {"user_id": user.id}}for standard users
File: backend/app/api/v1/units.py
- Add guards to unit management endpoints
- Service-level filtering based on user's scope (global/affiliation/unit)
Phase 3: Frontend UI Component Updates¶
3.1 Module Tables¶
Pattern to apply:
<script setup>
import { computed } from "vue";
import { useAuthStore } from "src/stores/auth";
import { hasPermission } from "src/utils/permission";
const authStore = useAuthStore();
const canEdit = computed(() =>
hasPermission(authStore.user?.permissions, "modules.headcount", "edit"),
);
</script>
<template>
<q-btn v-if="canEdit" label="Add New" @click="addNew" />
<q-badge v-else color="warning">View Only</q-badge>
</template>
Components to update:
- Headcount table component
- Equipment table component
- Professional travel table component
- All other module table components
Requirements:
- Hide "Add New" button if user lacks edit permission
- Disable/hide edit and delete buttons if user lacks edit permission
- Show "View Only" badge for read-only users
3.2 Enhanced Error Handling¶
Create error utilities (frontend/src/utils/errors.ts - NEW FILE)
-
parsePermissionError(error)- Extract permission details from 403 response -
showPermissionError(error)- Display user-friendly permission error toast
Enhance unauthorized page (frontend/src/pages/ErrorUnauthorized.vue)
Update API interceptor (frontend/src/api/http.ts)
- Parse error response body for permission details
- Show Quasar toast notification before redirecting
- Pass permission details to unauthorized page via query params
Phase 4: Error Messages¶
4.1 Create Custom Exception Classes¶
File: backend/app/core/exceptions.py (NEW FILE)
-
class PermissionDeniedError(Exception)withrequired_permission,action,message -
class InsufficientScopeError(PermissionDeniedError)for scope-related denials -
class RecordAccessDeniedError(PermissionDeniedError)for record-level denials
4.2 Implement Error Handlers¶
File: backend/app/core/exception_handlers.py (NEW FILE)
-
permission_denied_handler(request, exc: PermissionDeniedError) - Returns HTTP 403 with clear message format
- Register handlers in
backend/app/main.py
Phase 5: Backend Refactoring (Role Removal)¶
5.1 Security & Utilities¶
- Cleanup: Delete
require_roleandRoleCheckerfromsecurity.py. - Deprecation: Mark
has_role()as deprecated; redirect callers tohas_permission(). - OPA Context: Update input builders to calculate
user.permissionsfromroles_raw.
5.2 Route Guards¶
- Backoffice: Replace
adminchecks withrequire_permission("backoffice.users", "view/edit"). - Modules: Map
standardrole logic torequire_permission("modules.[name]", "edit"). - Units: Replace
unit_adminguards withrequire_permission("units", "manage").
5.3 Service Logic¶
- Data Filtering: Replace
if "admin" in user.roleswithget_data_filters(user, resource, action). - Abstraction: Services must depend on permission-based filters, not raw role strings.
Phase 6: Documentation¶
6.1 API Documentation¶
Update OpenAPI schema (backend/app/main.py)
- Add permission requirements to endpoint descriptions
- Document 403 error responses with examples
6.2 Developer Guide¶
Create (docs/developers/permissions.md - NEW FILE)
- How to add new permissions
- How to use
require_permission()decorator - How to implement data filtering in services
- How to add permission checks in frontend components
- Testing permission-based features