Update backoffice permissions (page-driven model)
Context¶
Backoffice access is currently a two-tier flag system (limitedAccess = Backoffice Administrator + Super Admin; superAdminOnly = Super Admin) and a single backend gate, system.users.edit, stands in for "is super admin". That gate is overloaded — it guards Logs, Configuration, and Pipeline Operations alike — so it cannot express per-page access or per-page data scope.
This plan adopts a page-driven permission model: one permission per frontend page, and the permission a page uses to reach a backend endpoint also determines that call's data scope. Permissions trickle down from the frontend UX to the backend.
This supersedes the earlier system.users-based draft of this plan and is coordinated with #459 (affiliation scoping). Naming stays underscored (backoffice.pipeline_operations, not -) to match every existing key and the generated OpenAPI contract. The calco2.superadmin → calco2.backoffice.admin role rename is deferred to a separate PR; this change is built against the current name.
Target model¶
Backoffice pages → permissions¶
| Page | Permission | Actions | Roles | Scope |
|---|---|---|---|---|
| Reporting | backoffice.reporting | view, export | metier, superadmin | affiliation (metier) / global (superadmin) |
| User Management | backoffice.users | view, edit, export | metier, superadmin | global (scope-less) |
| Documentation Editing | backoffice.documentation | view, edit | metier, superadmin | global (scope-less) |
| UI Texts Editing | backoffice.ui_texts (new) | view, edit | metier, superadmin | global (scope-less) |
| Configuration | backoffice.configuration | view, edit | superadmin only | global (scope-less) |
| Pipeline Operations | backoffice.pipeline_operations | view, edit | superadmin only | global (scope-less) |
| Logs | backoffice.logs | view | superadmin only | global (scope-less) |
system.users is removed: its three roles above replace it. backoffice.data_management is renamed to backoffice.configuration (the Configuration page); the metier role no longer receives it at all.
Module pages → permissions¶
Unchanged in shape: modules.<name> with view, edit, sync, unit-scoped for calco2.user.principal and own-scoped for calco2.user.standard (professional_travel + external_cloud_and_ai). See the role-permission matrix.
Scope-from-permission principle¶
The permission that authorises a call also fixes its scope. The data-sync endpoints make this concrete:
- Triggered from a module page → gated by
modules.<name>.sync→ scoped to the caller's unitinstitutional_id. - Triggered from the Configuration / Pipeline Operations page → gated by
backoffice.configuration/backoffice.pipeline_operations→ global.
Resolved (2026-06-02): split endpoints per page. Module-page sync stays on the unit-scoped
modules.<name>.syncendpoints; backoffice global sync gets its own endpoint(s) gated bybackoffice.pipeline_operations/backoffice.configuration. No shared endpoint serves both scopes.
Changes¶
Backend¶
app/models/user.py::calculate_user_permissions— emit the new keys.- Superadmin (global): bare
backoffice.reporting,backoffice.users,backoffice.documentation,backoffice.ui_texts,backoffice.configuration,backoffice.pipeline_operations,backoffice.logs. Dropsystem.users. - Metier (affiliation-scoped):
backoffice.reporting/<aff>only;backoffice.users,backoffice.documentation,backoffice.ui_textsscope-less (no/affsuffix). No configuration/pipeline/logs. - Affiliation anchor →
backoffice.reporting. Updatederive_backoffice_affiliationsandgate_backofficedefaults (app/utils/permissions.py,app/utils/scoping.py) and thebackoffice_reporting.py/backoffice.pycall sites.backoffice.usersis no longer scoped, so it can no longer be the anchor. - Re-gate
system.usersroutes to the page permission: app/api/v1/audit.py(4×) →backoffice.logs/ view.app/api/v1/year_configuration.py(3×) →backoffice.configuration/ edit.app/api/v1/data_sync.py: split the write/trigger endpoints — backoffice (global) sync gets dedicated endpoints gated bybackoffice.pipeline_operations/backoffice.configuration/ edit; module-page sync stays unit-scoped onmodules.<name>.sync. Read endpoints follow thebackoffice.data_management→backoffice.configurationrename.
Frontend¶
src/constant/permissions.ts— addbackoffice.configuration,backoffice.pipeline_operations,backoffice.logs,backoffice.ui_texts; removesystem.users; renamedata_management→configuration.src/router/routes.ts— guard each backoffice route with its own page permission (configuration/pipeline_operations/logs), notbackoffice.usersorsystem.users.src/constant/navigation.ts— keep Configuration/Pipeline/Logs as super-admin pages; the per-page guard now enforces it.- Regenerate OpenAPI types (
openapi.d.ts) from backend docstrings — do not hand-edit.
Docs¶
- Update the matrix and model sections of the consolidated 06-PERMISSION-SYSTEM in the same PR: new keys and the affiliation-anchor change.
Cleanup (fold in or split as a follow-up)¶
Surfaced while auditing; low-risk removals:
- Delete unused
User.has_role,User.has_role_global,User.refresh_permissions(zero callers;refresh_permissionsalso assigns a non-field). app/core/security.py::check_permissionhas no application caller (test-only) — remove or document.modules.*syncaction is granted but never enforced via a route gate — wire it (per the scope principle) or drop it.- Stray
co2.*role strings:unit_service.py:74,auth.py:338defaultrole="co2.user.std"— onlycalco2.*match a realRoleName.
Verification¶
calco2.superadmin→ all seven backoffice pages reachable; API mutations on configuration / pipeline / logs return 200.calco2.backoffice.metier→ Configuration, Pipeline Operations, Logs return 403 and the tabs/routes redirect to/unauthorized; Reporting is affiliation-scoped; Users / Documentation / UI Texts are reachable unscoped.- Affiliation narrowing keys off
backoffice.reporting(regression test intest_permission_scope_e2e.py). test_user_base_calculate_permissions.pyupdated for the new key set and green;matrix.mdregenerated to matchcalculate_user_permissions.make type-check(vue-tsc) and backenduv run pytestgreen.