Backend Permission System¶
The CO2 Calculator uses permission-based authorization rather than role checks scattered through the codebase. Permissions are computed dynamically from a user's roles on every request and are never stored in the database.
This is the single canonical reference for the permission system. It is long by design — overview, model, matrix, usage, the add-a-permission recipe, and the audit trail are consolidated here. Use the contents below to jump.
Contents¶
- Why permission-based authorization
- Implementation plans and related issues
- Files handling permissions
- The three authorization layers
- Permission model
- Role-permission matrix
- How permissions are computed
- Permission shape in the API
- Using permissions in code
- Resource-level policy
- Adding a new permission
- Audit trail
- Key principles
Why permission-based authorization¶
Permissions are derived from roles dynamically at request time and combined with scope-based data filtering and resource-level policy checks. This keeps business logic and authorization separated, makes new roles cheap to add, and produces an auditable trail.
The enforcement anchor is #414 — Enforce permissions across all routes. Backoffice reporting is scoped to a unit subtree: the metier scope token is an ACCRED unit cf (institutional_id) at any level, resolved to that unit's descendants via path_institutional_code (#862, superseding the earlier path_name/sub-perimeter approach of #459).
Implementation plans and related issues¶
| Plan | Issue | Status | Scope |
|---|---|---|---|
| Permission-based access control | #414 | Abandoned | Original hybrid (defense-in-depth) design; enforcement shipped under #414. |
| Authentication & integration hardening | #458 | Delivered | Pins the OAuth/JWT trust boundary; centralizes JWT→user resolution. |
| Professional travel permission conflict | #698 | Abandoned | Standard user with professional_travel.edit wrongly redirected. |
| Tighten unit-scoped permission gates | #977 | Delivered | Unit gating for carbon_report + unit_results; drops the coarse modules.* fallback. |
| Update backoffice permissions | #862 | In progress | Page-driven model: one permission per backoffice page; removes system.users. |
| Backoffice ACCRED affiliation scoping | #459 | Superseded | Scope backoffice.* by ACCRED sub-perimeter; path_name approach replaced by #862. |
| Affiliation scope by unit subtree | #862 | Delivered | Scope backoffice.reporting to a unit-cf subtree; scope ∩ (affiliation ∪ lvl4) clamp. |
Files handling permissions¶
Backend¶
Core authorization infrastructure:
app/models/user.py—RoleNameenum, User model, andcalculate_user_permissions().app/schemas/user.py—UserReadwith the@computed_fieldpermissions.app/utils/permissions.py— runtime checkhas_permission()andderive_backoffice_affiliations().app/utils/scoping.py— affiliation gategate_backoffice()and thebuild_scope_subtree_predicate()unit-subtree SQL predicate.app/core/security.py—require_permission()route decorator andis_permitted().app/core/policy.py— in-code policy evaluation (query_policy, resource access).app/services/authorization_service.py—get_data_filters(),check_resource_access().app/providers/role_provider.py— ACCRED role + affiliation (sub-perimeter) provider.app/api/deps.py— current-user and dependency wiring.
Enforced at (route and service consumers): app/api/v1/{audit,auth,carbon_report,carbon_report_module,carbon_report_module_stats,data_sync,factors,taxonomies,unit_results,units,users}.py, the backoffice*.py routers, and app/services/{unit_service,unit_totals_service,user_service}.py.
Frontend¶
The frontend checks permissions only, never roles. All permission logic lives in two files plus one router guard — consumers always call the auth store:
src/utils/permission.ts— store-free leaf: thePermissionActionenum, theFlatUserPermissionstypes, theMODULE_STATUS_PERMISSIONconstant, and the pure predicateshasPermission(),hasAnyScopePermission(),hasBackOfficeAreaPermission(),getModulePermissionPath(). No store imports, so it stays importable by pure unit tests.src/stores/auth.ts— the single entry point every consumer calls. Holds the sessionuser/permissionsand the stateful helpershasUserPermission,hasUserModulePermission,hasUserCanValidateModuleStatus,canUserAccessModule,hasUserBackOfficeAreaPermission,hasUserAnyScopePermission; re-exportsPermissionActionso callers import everything from here.src/router/guards/permissionGuard.ts— onepermissionGuardforbeforeEnter: module routes (meta.moduleEdit) need workspace-scoped view+edit on the route's module; back-office routes declaremeta.requiredPermission(+ optionalmeta.requiredAction), checked any-scope.src/router/routes.ts—meta.requiredPermission/meta.requiredAction(back-office) andmeta.moduleEdit(module pages) are the single source of truth:permissionGuardenforces them andCo2Sidebarreads the same meta to decide reachability, so router and nav can never drift.
Consumers (gated UI): src/pages/ErrorUnauthorized.vue, src/components/layout/{Co2Sidebar,Co2Header}.vue, src/components/organisms/layout/Co2ModuleSidebar.vue, src/components/organisms/module/{ModuleTable,SubModuleSection,HeadcountMemberSelect,ModuleTotalResult}.vue.
The three authorization layers¶
Three layers cooperate on every request:
- Route layer —
require_permission("path.resource", "action")rejects requests that lack the required permission with HTTP 403. - Service layer —
get_data_filters(...)returns scope-aware filters (global/unit/own) that the repository applies to queries. - Resource layer —
check_resource_access(...)evaluates an OPA-style policy on a single record (e.g. "API trips are read-only").
flowchart LR
A[HTTP request] --> B{require_permission}
B -- denied --> X[HTTP 403]
B -- allowed --> C[Service]
C --> D[get_data_filters]
D --> E[(Repository / DB)]
E --> F{check_resource_access}
F -- denied --> X
F -- allowed --> G[Audit log]
G --> H[HTTP 200 / 201]
X --> G Both the allow and deny branches emit an audit event so reviewers can trace who attempted what and why a decision was made.
Permission model¶
Paths and actions¶
Permissions use dot-notation paths combined with an action:
- Paths:
backoffice.reporting,backoffice.users,backoffice.documentation,backoffice.ui_texts,backoffice.configuration,backoffice.pipeline_operations,backoffice.logs,modules.headcount, … Module paths carry an explicit scope suffix:/<institutional_id>for unit-scoped grants (principal) or/<institutional_id>/ownfor own-scoped grants (standard user). See Explicit RoleScope. Custom affordance keys gate a specific UI control rather than a data domain — e.g.module.status/<institutional_id>gates the module-status validate button, granted to unit-breadth users (principal) only. Prefer a dedicated key over inferring breadth on the frontend. - Actions:
view(read),edit(create/update/delete),export(data export),sync(trigger imports — granted on everymodules.*for principals, and the backoffice global sync viabackoffice.configuration).
Domains are independent: backoffice roles do not grant module access. There is one permission per backoffice page (#862) — reporting, users, documentation, ui_texts (all metier + super admin) plus configuration, pipeline_operations and logs (super admin only) — alongside modules.* (calculation modules). The old backoffice.data_management and system.* keys were removed.
Roles¶
Roles are assigned to users and determine which permissions they receive. The canonical identifiers live in the RoleName enum in app/models/user.py:
calco2.user.standard— Standard user, own-scope access (professional travel and external cloud / AI).calco2.user.principal— Unit manager, unit-scope access (all modules).calco2.backoffice.metier— Backoffice administrator: affiliation-scoped reporting plus scope-less users / documentation / ui_texts.calco2.backoffice.admin— Super administrator with every backoffice page, including configuration / pipeline_operations / logs (nomodules.*grants).
Scopes¶
Scopes determine which data records a user can see:
- Global — see everything (super admin, backoffice metier).
- Unit — see records for assigned units (principals).
- Own — see only records the user created (standard users).
get_data_filters() translates the scope into structured filters:
{} # Global
{"unit_ids": ["12345", "67890"]} # Unit
{"user_id": "user-123"} # Own
Resources¶
A resource is a single data record (a specific headcount entry, a single travel record, etc.). Resource-level policy can enforce business rules that generic permission checks cannot — see Resource-level policy.
Role-permission matrix¶
The canonical human-readable view of which actions each role grants lives on the published back-office guide — Roles & permissions (source: co2-calculator-back-office-doc/docs/roles.md). The code source of truth is app/models/user.py::calculate_user_permissions — whenever you change a grant there, update the roles page in the same change set. This section documents only the scope mechanics the roles page omits.
Scope is explicit in the key¶
V = view, E = edit, X = export, S = sync. The grant matrix is on the roles page; what it does not show is how scope is encoded in the permission key:
- Principal grants are unit-scoped —
modules.X/<unit>. - Standard-user grants are own-scoped —
modules.X/<unit>/own. backoffice.reportingis affiliation-scoped for metier —/<cf>, where<cf>is the granted unit'sinstitutional_idat any level; the query resolves it to that unit's subtree (#862).- All other
backoffice.*keys are bare (global).
Unit-level module operations (e.g. PATCH a module's validation status) require unit breadth — principal or global. A standard user (own breadth) is rejected even though they may
edittheir own records, because their key ismodules.X/<unit>/own, notmodules.X/<unit>. This is enforced byresolve_module_scope/require_module_unit_scope. The frontend mirrors this for UX by gating the validate button on the dedicatedmodule.status/<cf>affordance (granted to principals only); the PATCH stays the source of truth.
Scope summary¶
| Role | Scope | Notes |
|---|---|---|
calco2.backoffice.admin | Global | Every backoffice.* page (bare keys); no modules.* grants |
calco2.backoffice.metier | Affiliation | backoffice.reporting/<cf> (unit-subtree scoped) + scope-less users / documentation / ui_texts |
calco2.user.principal | Unit | view, edit, sync on every modules.X/<unit> for assigned units |
calco2.user.standard | Own | view, edit on modules.{professional_travel,external_cloud_and_ai}/<unit>/own |
How permissions are computed¶
The backend calculates permissions during the GET /v1/session response:
- Read the user from the database with the
roles_rawfield. - Convert roles to
Roleobjects via theUser.rolesproperty. UserRead.calculate_permissions()callscalculate_user_permissions(roles).- Roles map to permission keys per the matrix.
- The computed permissions are included in the response.
Permissions are computed in-memory. No database writes occur.
Permission shape in the API¶
GET /v1/session returns a flat dictionary keyed by dot-notation path, with a list of action strings as the value. Module paths carry an explicit scope suffix — /<institutional_id> (unit, principal) or /<institutional_id>/own (own, standard user); backoffice.* keys are bare except backoffice.reporting/<cf> for an affiliation-scoped metier (the cf is resolved to a unit subtree at query time).
{
"id": "123456",
"email": "user@epfl.ch",
"roles": ["..."],
"permissions": {
"backoffice.reporting": ["view", "export"],
"backoffice.users": ["view", "edit", "export"],
"backoffice.configuration": ["view", "edit"],
"backoffice.pipeline_operations": ["view", "edit"],
"backoffice.logs": ["view"],
"modules.headcount/0184": ["view", "edit", "sync"],
"module.status/0184": ["edit"],
"modules.professional_travel/0184/own": ["view", "edit"]
}
}
Use the permissions field for access control. The roles field is for display only.
Using permissions in code¶
Route-level checks¶
Use the require_permission() dependency to protect endpoints:
from app.core.security import require_permission
from app.models.user import User
from fastapi import Depends
@router.get("/headcounts")
async def get_headcounts(
current_user: User = Depends(require_permission("modules.headcount", "view")),
):
"""Get headcounts. Requires modules.headcount.view; data is scope-filtered."""
service = HeadcountService(db, user=current_user)
return await service.get_headcounts()
On denial it raises HTTPException(403, detail="Permission denied"). The missing path.action is not echoed to the client — it is recorded in the Permission check denied log entry (see Audit trail).
Service-level data filtering¶
Use get_data_filters() to filter data by the caller's scope:
from app.services.authorization_service import get_data_filters
filters = await get_data_filters(
user=self.user, resource_type="headcount", action="list"
)
# {"unit_ids": [...]} for unit scope, {"user_id": "..."} for own, {} for global
return await self.repository.get_headcounts(filters=filters)
Resource-level access control¶
Use check_resource_access() for per-record rules:
from app.services.authorization_service import check_resource_access
has_access = await check_resource_access(
user=self.user,
resource_type="professional_travel",
resource={"id": trip.id, "created_by": trip.created_by,
"unit_id": trip.unit_id, "provider": trip.provider},
action="access",
)
if not has_access:
raise HTTPException(403, "Access denied")
Resource-level policy¶
In-code policy functions in app/core/policy.py enforce business rules for individual resources (OPA-style naming, no policy engine).
Professional travel policy¶
The authz/resource/access policy implements:
- API trips are read-only — cannot be edited by anyone (
provider == "api"). - Super admin — can edit all non-API trips (global scope).
- Principals — can edit manual/CSV trips in their assigned units.
- Standard users — can edit only their own manual trips.
decision = await query_policy("authz/resource/access", {
"user": user,
"resource_type": "professional_travel",
"resource": {"id": 123, "provider": "api", "created_by": "user-456", "unit_id": "12345"},
})
# {"allow": False, "reason": "API trips are read-only"}
Adding custom resource policies¶
Extend _evaluate_resource_access_policy() in app/core/policy.py:
if resource_type == "your_resource":
if some_condition:
return {"allow": False, "reason": "Your denial reason"}
return {"allow": True, "reason": "Access granted"}
Adding a new permission¶
1. Grant it in calculate_user_permissions¶
There is no separate registry — adding the key to a role branch in calculate_user_permissions is the declaration. The returned object is a flat dict of {path: [actions]}.
elif role_name == RoleName.CO2_SUPERADMIN.value:
if is_global_scope(scope):
permissions["backoffice.your_new_resource"] = merge_actions(
permissions.get("backoffice.your_new_resource"), ["view", "edit"],
)
Module permissions are unit-scoped — append scope_key so the key looks like modules.your_new_resource/<institutional_id>:
elif role_name == RoleName.CO2_USER_PRINCIPAL.value:
if is_role_scope(scope):
permissions[f"modules.your_new_resource{scope_key}"] = merge_actions(
permissions.get(f"modules.your_new_resource{scope_key}"),
["view", "edit", "sync"],
)
2. Protect the route¶
@router.get("/your-endpoint", responses={403: {"description": "Permission denied"}})
async def get_your_resource(
current_user: User = Depends(
require_permission("backoffice.your_new_resource", "view")
),
):
"""Required permission: ``backoffice.your_new_resource.view``."""
...
3. Filter data in the service (list endpoints)¶
filters = await get_data_filters(
user=self.user, resource_type="your_new_resource", action="list"
)
return await self.repository.get_all(filters=filters)
4. Add a resource-level rule (only if needed)¶
Extend _evaluate_resource_access_policy() in app/core/policy.py as shown in Resource-level policy.
5. Update the frontend¶
Call the auth store — the single entry point. Never check roles.
const canEdit = authStore.hasUserPermission(
"backoffice.your_new_resource",
PermissionAction.EDIT,
);
For a custom affordance key, add a MODULE_*_PERMISSION constant and a named helper (e.g. hasUserCanValidateModuleStatus) so call sites read intent, not a raw key string. Use the result for conditional rendering or to disable buttons. Backend require_permission remains the source of truth — frontend gating is UX only.
6. Test and ship¶
- Unit: assert 403 for unauthorised callers, 200 for authorised ones.
- Integration: verify scope filtering with
standard / principal / superadminfixtures. - Update the matrix row, the route docstring, and any changelog/ADR. Mention scope intent (global / unit / own / affiliation) so reviewers can match the grant against the backoffice subtree-scoping work (#862).
Audit trail¶
Every authorization decision is logged so reviewers can reconstruct who attempted what and why a request was allowed or denied. The trail spans all three checks — route, service, and resource — using structured logging.
What gets logged¶
require_permission— emits aPermission check deniedwarning on deny, withuser_id, the requiredpath, and the requestedaction.get_data_filters— emitsdata_filterevents with the chosen scope and the resulting filters dict.check_resource_access— emitsresource_accessevents with the resource type, the snapshot fields used by policy, and the decision plus itsreason.- HTTP 403 responses carry a generic
{"detail": "Permission denied"}body — the missingpath.actionis not echoed to the client.
Decision flow¶
- Request enters the FastAPI dependency chain.
require_permissionresolves the user, looks up the calculated permission, and logspermission_check.- On allow, the service computes scope filters and logs
data_filter. - The repository runs the query with those filters.
- For per-record actions,
check_resource_accessevaluates the policy and logsresource_accesswith thereason. - The route returns 200/201 (allow) or 403 (deny). Both branches have already produced an audit event upstream.
Debugging a 403¶
- Find the most recent
Permission check deniedwarning for the request'suser_id/request_id; itsextrapayload carries the requiredpathandaction. - Call
GET /v1/sessionand inspect the flatpermissionsdict for a key matchingpath(orpath/<institutional_id>for modules) whose action list containsaction. - If the grant looks correct but the request still fails, check the most recent
resource_accessevent for thereason(e.g.API trips are read-only). - If no deny event is present at all, suspect token expiry or an upstream auth failure — re-authenticate and retry.
Migrating legacy role checks¶
Legacy User.has_role(...) call sites should be migrated to the permission-based helpers: require_permission (route), get_data_filters (service), or check_resource_access (resource).
Key principles¶
- Permissions are calculated from roles, never stored.
- Frontend checks permissions, not roles — and frontend gating is UX only; the backend is the source of truth.
- Permissions recalculate on every
GET /v1/sessioncall. - Domains are independent and combine when a user holds multiple roles.
- Flat dot-notation keys with a list-of-actions value.
- Authorization is enforced at three layers: route (
require_permission), service (get_data_filters), and resource (check_resource_access). - Deprecated: direct role checks in business logic — use permissions.