Explicit RoleScope: own / unit / global
Problem¶
Scope level (own / unit / global) is implicit today: the same RoleScope(institutional_id="0184") means unit for a principal but own for a standard user — the level is decided by the role, not the scope object. Consequences:
calco2.user.principalandcalco2.user.standardget the same key shape,modules.X/<unit_iid>(role_provider.pyassigns the unit id to both;user.pyuses the samescope_key).check_module_permission_for_unitlooks upmodules.X/<unit_iid>, so both match — a unit-level operation (e.g. PATCH module status) cannot tell a principal from a std.- Own-scope is only enforced later, in
get_data_filters(std →{"user_id", scope:"own"}) and the resource policy (created_by). It is absent from the key, so the route/permission layer is blind to it.
This is why a std user leaks unit-level capability through edit (std holds edit on professional_travel + external_cloud_and_ai).
Design¶
Make scope explicit, mirroring the matrix (Global / Unit U / Own O + backoffice affiliation). Role.on becomes a Pydantic discriminated union keyed by kind:
class GlobalScope(BaseModel):
kind: Literal["global"] = "global"
class UnitScope(BaseModel):
kind: Literal["unit"] = "unit"
institutional_id: str # the unit
class OwnScope(BaseModel):
kind: Literal["own"] = "own"
institutional_id: str # the unit; owner is always current_user
class AffiliationScope(BaseModel):
kind: Literal["affiliation"] = "affiliation"
affiliation: str # ACCRED LVL3
Scope = Annotated[
Union[GlobalScope, UnitScope, OwnScope, AffiliationScope],
Field(discriminator="kind"),
]
Who vs. where: the owner is always current_user (never stored — it would be redundant and could go stale); the unit is explicit on both UnitScope and OwnScope (preserves ACCRED data, supports multi-unit std, enables an explicit cross-unit 403 rather than an empty own-filtered list).
Key shapes¶
The breadth must live in the key, because calculate_permissions() returns a flat dict that is the sole source of truth for both the backend resolver and the frontend.
| Role | scope | key | breadth |
|---|---|---|---|
| global | GlobalScope | modules.X | all |
| principal | UnitScope | modules.X/<unit> | all unit records |
| std | OwnScope | modules.X/<unit>/own | own records only |
| metier | AffiliationScope | backoffice.reporting/<aff> | sub-perimeter |
Resolver¶
Add resolve_module_scope(user, module, action, unit_id) -> "global"|"unit"|"own"|"denied" by which key matched (bare → global; …/<unit> → unit; …/<unit>/own → own). Then:
- Unit-level ops (PATCH module status) require
unit/global→ principal and superadmin pass, std (own) is excluded — no new action, noedit/syncoverload. - Record ops (std's own travel CRUD) accept
own/unit/global; anownmatch applies the existingcreated_by == current_userfilter.
Changes¶
app/models/user.py— the union; rewriteas_scope_key(own →/<unit>/own); replaceis_global_scope/is_role_scope/is_affiliation_scopewithmatch scope.kind; std branch emitsOwnScopekeys, principalUnitScope, metierAffiliationScope, superadmin bare.app/providers/role_provider.py— emit the right scope kind per role in Accred / Default / Test providers.app/utils/permissions.py—has_permissionlearns the…/<unit>/ownsuffix;derive_backoffice_affiliationsunchanged (affiliation).app/core/policy.py+app/services/authorization_service.py—resolve_module_scope;check_module_permission(_for_unit)gates by required breadth;get_data_filtersmapsown→created_by.app/api/v1/carbon_report_module.py— PATCH status requires unit scope.- Frontend —
permission.tshelpers parse the/ownsuffix; show unit-level controls only for unit/global; regenerateopenapi.d.ts. - Tests — migrate every
Role(on=...)construction; add own-vs-unit gating coverage.
Migration & compatibility¶
Pre-v1 drops the DB between deploys, so the changed roles_raw JSON shape needs no migration. Per the no-backward-compat rule this is a clean cut — the old RoleScope shape is removed, not dual-pathed.
Verification¶
- Unit: each scope kind → expected key shape; std
…/own, principal…/<unit>. - e2e: PATCH status — principal allowed, std denied even for
professional_travel; cross-unit principal/std denied; backoffice denied. - e2e: std own travel CRUD still works (own match →
created_byfilter); cross-unit std → 403 (not empty-200). - Full
uv run pytestgreen; frontendmake type-checkgreen.
Implementation checklist (handoff)¶
State as of 2026-06-02. The backend logic is done and import-verified; the remaining work is the mechanical test migration, the frontend, and docs. A model with limited context can finish from this section alone.
This branch carries two intertwined changes — the page-driven backoffice permissions (#862) and this RoleScope redesign. The checklist below covers everything still needed to get the suite green for both.
Mapping rules (apply these mechanically)¶
When migrating a Role(role=R, on=SCOPE) construction:
R == CO2_USER_PRINCIPAL,RoleScope(institutional_id=X)→UnitScope(institutional_id=X)R == CO2_USER_STD,RoleScope(institutional_id=X)→OwnScope(institutional_id=X)R == CO2_BACKOFFICE_METIER,RoleScope(affiliation=Y)→AffiliationScope(affiliation=Y)R == CO2_SUPERADMIN,GlobalScope(scope="global")orGlobalScope()→GlobalScope()- dict forms in
roles_raw: add akindkey —{"institutional_id": X}→{"kind": "unit"|"own", "institutional_id": X}(by role),{"affiliation": Y}→{"kind": "affiliation", "affiliation": Y},{"scope": "global"}→{"kind": "global"}. - Imports: drop
RoleScope; import only the specific scope classes used (UnitScope/OwnScope/AffiliationScope/GlobalScope) fromapp.models.user. - Key assertions: a standard-user module key gains the
/ownsuffix —modules.<m>/<unit>→modules.<m>/<unit>/own. Principal keys staymodules.<m>/<unit>(no suffix). Backoffice/affiliation keys unchanged. - Behaviour flips to assert: std is denied unit-level ops (PATCH module status) even on
professional_travel; a principal passes module-flow reads (active-pipelines,jobs/year,jobs/{id}/stream) because it cansync; std (no sync) is denied those.
Done (do not redo) — backend¶
-
app/models/user.py—GlobalScope/UnitScope/OwnScope/AffiliationScopediscriminated union (Scope,discriminator="kind");RoleScoperemoved;as_scope_keyemits""//<unit>//<unit>/own//<aff>; role branches gated onscope.kind. -
app/providers/role_provider.py—_unit_or_own_scope()helper; principal→UnitScope, std→OwnScope, metier→AffiliationScope, superadmin→GlobalScope. -
app/core/policy.py— data-filter splitsUnitScope→unit filter vsOwnScope→own filter;require_module_unit_scope()added; importsresolve_module_scope. -
app/core/role_priority.py,app/services/user_service.py,app/seed/seed_fake_user_unit.py—isinstance(.on, (UnitScope, OwnScope)). -
app/providers/test_fixtures.py—TEST_ROLESuse explicit scopes. -
app/utils/permissions.py—has_permissionmatches the/ownkey wheninstitutional_idis set; newresolve_module_scope()(global>unit>own>denied). -
app/api/v1/carbon_report.py— status PATCH gated byrequire_module_unit_scope(unit/global only); droppedrequire_edit_module. -
app/utils/scoping.py— removed brokencan_edit_module/require_edit_module. -
tests/unit/models/test_user_base_calculate_permissions.py— migrated, green.
TODO — test migration (each: imports + scope ctor + /own assertions)¶
-
tests/unit/utils/test_permissions.py— builders +_modules(own=)+_STD_KEYS/own; dict-rolekind; subsumes test → subset semantics. 83 passed. -
tests/integration/v1/test_permission_scope_e2e.py— builders migrated. -
tests/integration/v1/test_unit_gating_e2e.py— builders +_user/_scoped_*_usernow expose realcalculate_permissions; status-PATCH principal 200 / std 403 viarequire_module_unit_scope. -
tests/integration/v1/test_professional_travel_trips_map.py— builders migrated. -
tests/integration/v1/test_headcount_members_permission.py— builders migrated. -
tests/unit/core/test_policy.py—UnitScope; dict roles carrykind. -
tests/unit/providers/test_role_provider.py— emitted scopes assertOwnScope/UnitScope/AffiliationScope. -
tests/unit/services/test_role_sync_service.py— stdOwnScope; dictkind. -
tests/unit/services/test_user_service.py— generic builderUnitScope; no-id role →GlobalScope. -
tests/unit/models/test_user_serialization.py— round-trip assertskind. -
tests/unit/v1/test_carbon_report_module.py— builders migrated. -
tests/unit/v1/test_travel_table_visibility.py— builders migrated. - Also:
tests/unit/v1/test_carbon_report.pystatus tests patch the newrequire_module_unit_scope;test_year_configuration_list.pyfake_is_permitted→backoffice.configuration;test_csv_upload_e2e.pyGlobalScope(). - Full
uv run pytest: 1861 passed, 1 failed. The lone failure (test_stats_json_pg::…_scope3, a stats-math assertion) is pre-existing and unrelated — diff vsdevtouches no stats code; not a 403/scope issue.
TODO — frontend (covers #862 + this redesign)¶
-
frontend/src/constant/permissions.ts— addedbackoffice.configuration,backoffice.pipeline_operations,backoffice.logs,backoffice.ui_texts; removedsystem.users;data_managementalready gone. NestedBackOfficePermissionsupdated too. -
frontend/src/utils/permission.ts—hasAnyScopePermissionalready matchesmodules.<m>/<unit>/own(prefix); addedhasUnitScopePermission(matches bare/<unit>but not/own) for unit-level controls; dropped the removedsystem.*branch inhasBackOfficeAreaPermission. -
frontend/src/router/routes.ts— per-page guards fixed: pipeline-operations →backoffice.pipeline_operations, ui-texts →backoffice.ui_texts, logs →backoffice.logs, back-office redirectbackoffice.*→backoffice.reporting, doc-view →backoffice.documentation. Also fixedauth.tshasUserPermissionto match the/ownkey (std keep module CRUD); added storehasUserUnitScopePermission. - Regenerated
frontend/src/types/api/openapi.d.ts(from live backend); scrubbedsystem.usersbackend docstrings first → 0system.usersleft. -
make type-check(vue-tsc) green.
TODO — docs¶
-
docs/src/backend/06-PERMISSION-SYSTEM.md— matrix rebuilt to the page-driven keys + explicit scope; std/ownkey shape, affiliation(A)reporting, and the unit-level modulestatusgate documented; API shape JSON + role/scope text refreshed; removed the staledata_management/system.usersrows and the obsolete "in flight" note.mkdocs --strictgreen. - Flip this plan's frontmatter
statustodeliveredand fillissue:once an issue exists.
Verify (final)¶
cd backend && uv run pytest -q
cd frontend && make type-check
cd docs && npx prettier --check src/**/*.md && uv run mkdocs build --strict