Backoffice ACCRED affiliation scoping
Phase 2 (2026-05-26): production sortpath shape + frontend gate¶
Phase 1 (PR #1271) shipped against a single-token sortpath assumption ("Engineering"). The first real ACCRED payloads showed sortpath is a space-separated 4-level path (e.g. "EPFL ENAC ENAC-SG ENAC-IT"), so the affiliation-suffixed permission keys looked like backoffice.users/EPFL ENAC ENAC-SG ENAC-IT — readable but unusable as a sub-perimeter token. Phase 2 resolves Open Question #1 and closes the Phase 1 frontend follow-up.
Backend — LVL3 trim (backend/app/providers/role_provider.py):
- ACCRED sortpath is now split on whitespace and reduced to LVL3 (index 2, e.g.
"ENAC-SG") before being assigned toRoleScope.affiliation. The cut-off was confirmed against real EPFL payloads: LVL3 is the unit-of-interest for backoffice scoping; LVL4 is too granular and LVL1/2 are too broad. - Authorizations whose sortpath has fewer than 3 levels are logged at warning level and dropped (cannot resolve a meaningful affiliation). Picked "strict skip" over "use deepest token" to surface upstream data issues rather than silently miscategorise users.
- Module-level constant
AFFILIATION_LEVEL = 3documents the level choice; tests pin the trim (test_accred_fetch_roles_with_backoffice_metieruses a 4-token sortpath) and the skip path (test_accred_backoffice_metier_short_sortpath_skipped).
Backend — principal no longer grants backoffice.users.edit (backend/app/models/user.py):
- The Phase 1 plan inherited a grant where
CO2_USER_PRINCIPALemitted an un-scopedbackoffice.users: ["edit"]"so principals could assign std roles." Per the current role spec (Standard/Principal are unit-area roles; Backoffice Admin/Super Admin are the only backoffice-area roles), this was a leak: a principal could passhas_permission("backoffice.users", "edit", any_scope=True)and reach backoffice surfaces. Removed. - Knock-on test updates:
test_user_principal_unit_scopenow asserts nobackoffice.*keys leak from principal-only roles; theTestRoleCompositionKeyscases forprincipal-A,std+principal-same-unit,principal-A+std-Bdrop the{backoffice.users}allowance and move it toforbidden.
Frontend — generic back-office area gate (frontend/src/utils/permission.ts, frontend/src/stores/auth.ts, frontend/src/components/layout/Co2Header.vue):
- Added
hasBackOfficeAreaPermission(permissions, action)which scans for anybackoffice.*ORsystem.*key (bare or/<aff>-suffixed) grantingaction. Both prefixes are matched because the back-office area covers bothbackoffice.*features (reporting, users, data management, documentation) andsystem.*features (Super Admin tabs: configuration, pipeline operations, logs). Exposed viaauthStore.hasUserBackOfficeAreaPermission(action). Co2Header.vue'shasBackOfficeAccesscomputed now calls the broader helper. Affiliation-scoped users (whose only key shape isbackoffice.X/ENAC-SG) AND Super Admins with onlysystem.*grants both see the entry button.- Comment fix at
backoffice_reporting.py:_affiliation_predicateto reflect the real sortpath shape ("space-separated 4-level hierarchy;role_provider.pyextracts LVL3").
Frontend — path-specific any-scope guard (frontend/src/utils/permission.ts, frontend/src/stores/auth.ts, frontend/src/router/guards/permissionGuard.ts, frontend/src/components/layout/Co2Sidebar.vue):
- Closes the Phase 1 frontend follow-up. Added
hasAnyScopePermission(permissions, path, action)mirroring the backend'shas_permission(..., any_scope=True): matches the bare path OR anypath/<*>variant. Exposed viaauthStore.hasUserAnyScopePermission(path, action). requirePermission(path, action)inpermissionGuard.tsnow uses the any-scope check, so anENAC-SG-scoped backoffice admin can enterback-office/*routes despite holding onlybackoffice.users/ENAC-SG. The guard now also emitsconsole.warnon denial — previously the unauthorized redirect was silent, making misconfigured permissions hard to diagnose.Co2Sidebar.vue'shasBackOfficeEditPermissionswitched to the any-scope check (same root cause).- Module routes are unaffected — they use
requireModuleEditPermission(workspace-scoped), which keeps unit isolation. The any-scope mode is documented as "do not use for unit-data routes" inpermission.ts.
Regression coverage: frontend/tests/unit/permission.spec.ts — 11 cases total: 7 for hasBackOfficeAreaPermission (scoped/bare backoffice keys, reporting-only scoped user, system.* key, action mismatch, module-only user, null permissions) + 4 for hasAnyScopePermission (scoped edit, bare edit, path isolation against module keys, prefix isolation against backoffice.users_other).
Backend — open the broader /backoffice/* surface to scoped users (backend/app/api/v1/backoffice.py, backend/app/utils/scoping.py):
- Reverses the Phase 1 "left explicit" decision after evidence that the strict gate blocks normal navigation for ENAC-SG (and other LVL3) backoffice admins. All six endpoints —
/units,/export,/years,/report/usage,/report/detailed,/report/results— dropDepends(require_permission(...))forDepends(get_current_active_user)+ inlinegate_backoffice(user, action)from the newapp.utils.scopingmodule. - Server-side affiliation enforcement:
narrow_path_affiliation(filters.path_affiliation, is_global, affiliations)intersects the caller-suppliedpath_affiliationquery param with the caller's affiliation set; a scoped user cannot escape their scope by passing a foreign affiliation. Empty intersection → empty result (defence-in-depth, mirroring the existing/backoffice-reporting/unitspattern). /yearshad nopath_affiliationfilter at all, so the query was extended withJOIN units ON CarbonReport.unit_id = units.id+ the affiliation predicate when the caller is scoped. Global callers keep the original distinct-year query (no join)._affiliation_predicateand the gate helper moved out ofbackoffice_reporting.pyintoapp/utils/scoping.py(build_affiliation_predicate,gate_backoffice,narrow_path_affiliation).backoffice_reporting.pynow imports the shared versions; behaviour unchanged (Phase 1 e2e tests still green).- Regression coverage:
TestBackofficeYearsAffiliationScopinginbackend/tests/integration/v1/test_permission_scope_e2e.py— 5 cases pinning global/scoped/cross-affiliation/std-denied/principal-denied behavior. The principal-denied case is the explicit pin for the Phase 2 grant removal (CO2_USER_PRINCIPALno longer leaksbackoffice.users.edit).
Backend — sweep remaining data_sync.py strict gates + tighten data_management action set (backend/app/api/v1/data_sync.py, backend/app/utils/scoping.py, backend/app/models/user.py):
- Added FastAPI dependency factory
require_any_scope(action, anchor_path)inscoping.py— drop-in replacement forrequire_permissionfor routes that only need the 403 gate (not the affiliation tuple). Converted 10 strict-gatedview-action routes indata_sync.py(/jobs/by-status,/jobs/year/{year},/jobs/year/{year}/latest,/workers,/active-pipelines,/active-pipelines/year/{year},/recalculation-status,/pipelines,/pipelines/{pipeline_id}, plus a tenth view-gated endpoint). Sync-action routes (/dispatch,/factor reupload, etc., 6 total) stay on strictrequire_permission— they are Super-Admin-only per the role spec. - Tightened the
CO2_BACKOFFICE_METIERpermission emission: scoped backoffice metier now getsbackoffice.data_management/<aff>: ["view", "export"]only (was["view", "edit", "export", "sync"]). Matches the EPFL role spec — Backoffice Administrator has read/export but not write/sync on data_management; Super Admin keeps the full set on the bare key. Closes the latent leak where opening a sync route any-scope would have inadvertently granted scoped users pipeline-trigger / factor-mutation rights. - Regression coverage:
TestBackofficeAffiliationScoping.test_affiliation_scope_emits_scoped_keys_onlynow asserts the full action sets for all four scoped keys; newtest_scoped_backoffice_lacks_data_management_syncandtest_superadmin_has_data_management_edit_and_syncpin the asymmetry. TheTestActivePipelinesPerYearGatee2e class already covers the any-scope gate pattern end-to-end.
Delivered (Phase 1)¶
Shipped in PR #1271 on feat/459-backoffice-accred-affiliation-scoping. Divergences from the original plan:
- URL prefix: the affected endpoints are mounted at
/api/v1/backoffice-reporting/{affiliations,units}(not/api/v1/backoffice/...). The plan referenced the file pathbackend/app/api/v1/backoffice_reporting.pycorrectly; only the public URL was misstated. as_scope_keybranching: backoffice metier withRoleScope(institutional_id=...)falls back to un-scoped keys rather than emitting a meaninglessbackoffice.users/<iid>. ACCRED never produces this shape today (onlyGlobalScopeorRoleScope(affiliation=...)), so the defensive un-scoped degrade is safer than introducing a key shape no consumer matches.- Permission helper: added
derive_backoffice_affiliations(permissions, anchor_path)inbackend/app/utils/permissions.pyto parse(is_global, affiliations)from the permission dict — both endpoints share it via_gate_backoffice_users_viewinbackend/app/api/v1/backoffice_reporting.py. path_namepredicate: implemented asconcat(' ', coalesce(path_name, ''), ' ') ILIKE '% <aff> %', which covers both observed separators (' > 'and plain space) in a single clause, handles NULLpath_name, and avoids theSV ↔ SVOPSboundary bug.- Empty-affiliation semantic (open question #6): chose
200 []— the gate accepts (the user is a backoffice manager) and the predicate filters everything out. - Cross-endpoint blast radius (open question #5): the broader
/api/v1/backoffice/*surface (backend/app/api/v1/backoffice.py: list reporting units, exports;backend/app/api/v1/data_sync.py: sync endpoints;backend/app/api/v1/factors.py) still gates viarequire_permission, so affiliation-scoped users will now 403 on those routes. This is the security-correct posture (an SV manager has no business exporting global user lists or triggering global syncs) and is left explicit. Follow-up scoping for these endpoints, if needed, can reuse_gate_backoffice_users_view+_affiliation_predicate.
The four pinning tests in TestBackofficeScopingCurrentBehavior were replaced by TestBackofficeAffiliationScoping + TestHasPermissionAnyScopeAffiliation in backend/tests/unit/utils/test_permissions.py. The test_backoffice_and_system_keys_never_scoped invariant was split into a strict system.* invariant and a relaxed backoffice.* rule that allows non-digit (affiliation) suffixes. End-to-end coverage lives in TestBackofficeAffiliationScopeEndToEnd in backend/tests/integration/v1/test_permission_scope_e2e.py.
Known follow-up (frontend)¶
frontend/src/utils/permission.ts:hasPermission() does a strict path in permissions lookup. After this PR, an affiliation-scoped backoffice user holds only backoffice.users/<aff> keys (no bare backoffice.users), so the following call sites will hide menu items and block navigation for them:
frontend/src/components/layout/Co2Header.vue:53— backoffice header entry.frontend/src/components/layout/Co2Sidebar.vue:23— backoffice sidebar entry.frontend/src/router/routes.ts(8 occurrences) — backoffice route guards.
This is out of scope for this PR (backend-only per the issue scope), but is a hard prerequisite for affiliation-scoped backoffice users to actually USE the affiliation-filtered endpoints. Recommended fix: extend hasPermission() (or add a sibling hasAnyScopePermission()) to fall back to path/<*> keys when the bare key is absent, mirroring the backend any_scope=True mode. Track as a follow-up issue.
Problem¶
RoleScope.affiliation exists on the user model (backend/app/models/user.py:31) and the ACCRED provider already populates it from the reason.resource.sortpath field of each calco2.backoffice.metier authorization (backend/app/providers/role_provider.py:544-554). However, the affiliation is currently ignored downstream:
as_scope_key()returns""for anyRoleScopewith only an affiliation set (backend/app/models/user.py:137-138, 142-143), with an inline comment acknowledging the gap.calculate_user_permissions()emits a fixed, unscoped key set forCO2_BACKOFFICE_METIER—backoffice.reporting,backoffice.users,backoffice.data_management,backoffice.documentation— regardless of whether the role isGlobalScopeorRoleScope(affiliation=...)(backend/app/models/user.py:164-186)./backoffice/affiliationsand/backoffice/units(backend/app/api/v1/backoffice_reporting.py:20-108) gate onrequire_permission("backoffice.users", "view")and return all active units with no per-caller filtering.
Concrete impact: a Faculty SV backoffice manager whose ACCRED sortpath resolves to "SV" has the same blast radius as a global backoffice admin — they see STI, IC, ENAC, CDH, CDM units in the affiliation/unit dropdowns and reporting tables. This violates the EPFL accreditation model and the explicit ACCRED contract that backoffice roles are sub-perimeter-bound.
Decision applied¶
Backoffice "sub-perimeter" comes from ACCRED-provided affiliation(s) on the user. Populate RoleScope.affiliation from ACCRED (already done); emit backoffice.*/{affiliation} keys; filter /backoffice/affiliations, /backoffice/units, and any backoffice reporting endpoints by the user's affiliations.
GlobalScope (superadmin, plus any backoffice role explicitly granted globally) continues to bypass affiliation filtering. Affiliation acts purely as a narrowing predicate on top of the existing permission gate.
Files to change¶
backend/app/models/user.pyas_scope_key()(lines 127-145): returnf"/{s.affiliation}"instead of""when onlyaffiliationis set.calculate_user_permissions()(lines 164-186): forCO2_BACKOFFICE_METIERwithRoleScope(affiliation=...), emitbackoffice.reporting/{affiliation},backoffice.users/{affiliation},backoffice.data_management/{affiliation},backoffice.documentation/{affiliation}.GlobalScopekeeps the bare keys.backend/app/api/v1/backoffice_reporting.pylist_affiliations()(lines 20-59): swaprequire_permission("backoffice.users", "view")for an inline gate that useshas_permission(..., any_scope=True), then filter by the caller's affiliation set.list_units()(lines 62-108): same gate change + same affiliation filter.backend/tests/unit/models/test_user.py(or the existing matching test file — locate during impl): add cases foras_scope_key(RoleScope(affiliation="SV"))→"/SV"andcalculate_user_permissions([backoffice_metier @ affiliation=SV])emitting only the fourbackoffice.*/SVkeys.backend/tests/integration/v1/test_permission_scope_e2e.py: add_backoffice_scoped(affiliation)helper and three new test methods (see Tests section).
Files explicitly not changed:
backend/app/providers/role_provider.py— ACCRED →RoleScope.affiliationmapping is already in place (verified at lines 544-554; covered bytest_role_provider.py:301-335).backend/app/utils/permissions.py—has_permission(..., any_scope=True)already supports the lookup pattern we need; no signature change.backend/app/core/security.py—require_permission/is_permittedkeep their current shape; the backoffice endpoints simply stop usingrequire_permissionand inline a scoped check, mirroring the taxonomy-route precedent already documented inutils/permissions.py:32-38.frontend/src/components/organisms/backoffice/reporting/— backend-side narrowing is sufficient; the existing dropdowns already render whatever the API returns.
Approach¶
-
Fix
as_scope_key(backend/app/models/user.py:127-145). Replace the twoif s.affiliation: return ""branches withreturn f"/{s.affiliation}". Both theRoleScopeanddictbranches need updating. This is a single-line semantic change but it is load-bearing for everything downstream. -
Branch
calculate_user_permissionson scope type for backoffice (backend/app/models/user.py:164-186). The current code emits the same four bare keys whetheris_global_scope(scope)oris_role_scope(scope). Split the branch: - If
is_global_scope(scope): keep emitting the bare keys (backoffice.reporting,backoffice.users,backoffice.data_management,backoffice.documentation) — global backoffice keeps cross-affiliation reach. - If
is_role_scope(scope)andscope_key(fromas_scope_key) is non-empty: emitbackoffice.reporting{scope_key},backoffice.users{scope_key},backoffice.data_management{scope_key},backoffice.documentation{scope_key}. A user with twoCO2_BACKOFFICE_METIERroles onSVandSTIends up with bothbackoffice.users/SVandbackoffice.users/STI(natural union via the merge_actions pattern). -
The
CO2_SUPERADMINbranch (line 275-303) already requiresis_global_scope, no change. -
Swap the endpoint gate in
backend/app/api/v1/backoffice_reporting.py. Replacecurrent_user: User = Depends(require_permission("backoffice.users", "view"))on both endpoints withcurrent_user: User = Depends(get_current_active_user)and callhas_permission(current_user.calculate_permissions(), "backoffice.users", "view", any_scope=True)at the top of each handler — raiseHTTPException(403)on miss. Rationale:require_permissiondoes a literal-path lookup viais_permitted→fnmatch(backend/app/core/security.py:210-217) which fails for users whose only key isbackoffice.users/SV. Theany_scope=Truepath onhas_permission(backend/app/utils/permissions.py:55-67) is the exact mechanism we need and matches the taxonomy precedent. -
Derive the caller's affiliation set inside each handler. Pseudocode:
perms = current_user.calculate_permissions()
is_global = "backoffice.users" in perms # bare key only emitted for GlobalScope
affiliations = {
k.removeprefix("backoffice.users/")
for k in perms
if k.startswith("backoffice.users/")
}
If is_global is true, skip the affiliation predicate entirely. Otherwise apply it. (Use backoffice.users as the canonical anchor because both endpoints already gate on that key; the four backoffice keys are emitted in lockstep so any one would work.)
- Apply the affiliation predicate to the SQL queries. Both endpoints already build a
select(Unit)(lines 41, 77). For affiliation-scoped callers, add anOR-joinedpath_name ILIKEclause per affiliation. The recommended shape (see Open Questions for the field choice):
from sqlalchemy import or_
if not is_global and affiliations:
query = query.where(
or_(*[col(Unit.path_name).ilike(f"% {aff} %") for aff in affiliations])
)
elif not is_global and not affiliations:
return [] # defence-in-depth: scoped user with no affiliations sees nothing
Whitespace boundaries (% {aff} %) avoid SV matching SVOPS etc. The path_name column is space-separated ("EHE ASSOCIATIONS SCIENC-CULT 180C", see backend/app/models/unit.py:76-79).
-
Multi-affiliation users (multiple
CO2_BACKOFFICE_METIERroles) are handled as a union: the permissions dict naturally accumulates one scoped key per affiliation, and the SQL predicate is anORacross the set. A user with affiliations{SV, STI}sees the union of SV's and STI's units. -
GlobalScopebackoffice / superadmin keep the barebackoffice.userskey, so theis_globalshort-circuit in step 4 skips filtering and they see everything as today.
Tests¶
Backend unit tests (backend/tests/unit/models/test_user.py and backend/tests/unit/utils/test_permissions.py)¶
test_as_scope_key_affiliation_returns_prefixed_string:as_scope_key(RoleScope(affiliation="SV"))→"/SV", both via theRoleScopeobject branch and via thedictbranch.test_as_scope_key_global_still_returns_empty: regression guard forGlobalScope()→"".test_calculate_user_permissions_backoffice_metier_with_affiliation_emits_scoped_keys: a singleCO2_BACKOFFICE_METIER @ affiliation=SVrole yields exactly{backoffice.reporting/SV, backoffice.users/SV, backoffice.data_management/SV, backoffice.documentation/SV}with correct action lists, and no barebackoffice.*keys.test_calculate_user_permissions_backoffice_metier_global_unchanged: regression guard —GlobalScopestill emits bare keys.test_calculate_user_permissions_backoffice_multi_affiliation_unions: twoCO2_BACKOFFICE_METIERroles onSVandSTIyield bothbackoffice.users/SVandbackoffice.users/STI.test_has_permission_any_scope_matches_affiliation_keys:has_permission({"backoffice.users/SV": ["view"]}, "backoffice.users", "view", any_scope=True)→True; same call withany_scope=False→False(pins the rationale for the endpoint change in step 3).
Backend integration tests (backend/tests/integration/v1/test_permission_scope_e2e.py)¶
Add a helper alongside the existing _principal/_std/_backoffice/_superadmin:
def _backoffice_scoped(affiliation: str) -> Role:
return Role(role=RoleName.CO2_BACKOFFICE_METIER, on=RoleScope(affiliation=affiliation))
Add a new test class TestBackofficeAffiliationScopeEndToEnd covering /api/v1/backoffice/units (and the same shape for /affiliations if convenient):
test_global_backoffice_sees_all_units: caller with_backoffice()(GlobalScope) → 200, full unit list. Pins the global short-circuit.test_scoped_backoffice_sees_only_in_affiliation_units: caller with_backoffice_scoped("SV")against a fixture that returns one SV unit and one STI unit → 200, response contains only the SV unit.test_scoped_backoffice_cross_affiliation_returns_empty: same caller against an STI-only fixture → 200 with empty list (the request is permitted — the user hasbackoffice.users/SV— but the predicate filters everything out). This is the cross-affiliation isolation guarantee.test_scoped_backoffice_no_affiliations_denied_or_empty: caller with role wired but affiliation set empty (defence-in-depth from step 5) → 200 empty. Document the chosen semantic in the assertion.test_unscoped_request_denied_for_non_backoffice: caller with only_std(UNIT_IID)→ 403. Confirms the gate still rejects non-backoffice callers.test_superadmin_sees_all_units: regression guard, mirrors the pattern at line 174.
Use the existing _wire pattern for dependency overrides; supplement with a unit-list fixture function rather than reusing _ALL_MEMBERS (which is headcount-specific).
Verification¶
cd backend
uv run pytest tests/unit/utils/test_permissions.py tests/unit/models/test_user.py -xvs
uv run pytest tests/integration/v1/test_permission_scope_e2e.py -xvs
make backend-dev # then curl /api/v1/backoffice/units with a scoped backoffice token, verify filtered result
Sanity-check the full backoffice suite as well — uv run pytest tests/integration/v1/test_backoffice_reporting.py -xvs if such a file exists; otherwise run the broader integration sweep uv run pytest tests/integration/v1/ -xvs.
Manual e2e (post-deploy):
- Log in as a known SV-scoped backoffice user.
- Open the Reporting page, inspect the affiliation dropdown — should only list SV-tree units (Faculty SV + its institutes).
- Open the Units table — only SV units should appear.
- Repeat as a global backoffice / superadmin — every active unit should appear.
Open questions¶
- Which
Unitfield matches ACCREDsortpath? The test fixture uses"Engineering"(a school-name-shaped string); production values are unconfirmed. Candidates: path_name(e.g."EHE ASSOCIATIONS SCIENC-CULT 180C") — human-readable, space-separated, supportsILIKEboundary matching. Recommended unless evidence shows otherwise.path_institutional_code(numeric, space-separated) — would require anaffiliation→codelookup table; not justified by current data.-
A new dedicated column populated during ACCRED unit sync — overkill for v0.x. Confirm the production
sortpathshape against a real ACCRED payload before locking step 5. -
ACCRED
sortpathshape: single string or list? Read ofrole_provider.py:544-554and the existing test (test_role_provider.py:312) shows a single string per authorization. A user with multiple sub-perimeters has multipleCO2_BACKOFFICE_METIERauthorizations, each with its ownsortpath, which our union-via-key-accumulation handles cleanly. Confirm with a real ACCRED payload thatsortpathnever returns a list/comma-string. -
Union vs intersection across affiliations. The plan assumes union (a user with
{SV, STI}sees both). This matches the natural multi-role semantics of every other role in the system and the implicit ACCRED contract (each authorization is an additive grant). Confirm with the product owner that no scenario calls for intersection. -
backoffice.documentationscoping. Should documentation editing also be sub-perimeter-bound, or is documentation a shared corpus across the whole calculator (in which casebackoffice.documentationstays unscoped even for affiliation-scoped backoffice users)? Plan currently treats it the same as the other three — scope it. Easy to reverse before merge if product disagrees. -
Endpoints beyond
/affiliationsand/units. Issue title mentions "Reporting" generically. Auditbackend/app/api/v1/for any other route gated onbackoffice.*that returns unit-keyed data (e.g. backoffice carbon-report listings, export endpoints). If any exist, they need the sameany_scope=True+ predicate treatment; list them in the implementation PR description. -
Empty-affiliation scoped user — 200 with
[]or 403? Plan picks200 [](defence-in-depth: gate passes, predicate filters everything). Alternative is 403. Pick one explicitly during implementation and pin with the test from the Tests section.