Permissions

List roles that can be assigned by company admins

Roles a company admin may assign — THEIR company's, minus any that transitively grant a NEVER_ASSIGNABLE permission.

On catalog_router (gate: admin) deliberately, since 2026-07-30. It used to sit on protected_router, which carries system_admin — while POST /api/invitations needs only admin. A real company admin therefore got a 403 here, the frontend swallowed it into an empty list, the invite dialog hid the Role field entirely, and every invitee silently fell through to the company's Completo copy (all 22 modules). Finding B1, reproduced in a browser 2026-07-30; see docs/planning/permission-system.md §"Step 10a-2".

admin is the right bar precisely because it is the gate on the write this list feeds: reading the names of roles you are permitted to assign is strictly weaker than assigning one. Two guards in the body — and not the router — are what make it sufficient:

  1. Tenant scope. idcompany comes from request.state.current_user, never from the client. Every company owns a private copy of the role catalogue, so the pre-2026-07-29 unscoped list_roles() handed every admin every other tenant's role names and ids, which the assignment endpoints accept.

  2. No escalation. Any role carrying a name in actions.NEVER_ASSIGNABLEsystem_admin, admin, view_all_pvs — is dropped. GET /company-roles has no such filter, which is why the invite picker must NOT be repointed at it.

    Filtering on system_admin ALONE was not enough once this route moved to catalog_router. The audience changed with the gate: on protected_router the only readers were Multigest staff, and blocking the one permission they could not already grant themselves was sufficient. On admin the readers are every tenant admin, and the set exists precisely because admin and view_all_pvs are the escalation and cross-PV-data names (see actions.py:88-97). The seeded company copies of socio and Administrador DO carry the legacy admin row, and the consumer of this list writes the chosen role into Z_UserPvRoles, which app/auth.py reads straight into pv_permissions — so offering "Sócio" in the invite dialog was offering company-admin rights. The invite endpoint re-checks the same set server-side (invitations.py::_assert_role_is_assignable); this filter keeps the picker from showing a choice that can only be refused.

Resource permissions stays in PLATFORM_RESOURCES, so this path carries no MODULE gate — on purpose. The invite dialog lives on /users (modulo.utilizadores) and the role editor on modulo.configuracoes; module- gating this list would 403 an admin who holds one and not the other, which is the same silent-empty-list failure one module over. It is registered in PLATFORM_RESOURCE_TENANT_PATHS so the coverage test knows it is tenant-facing.

System templates are excluded (include_templates=False). They belong to no company, so assign_role and set_user_pv_role both refuse them — listing them as "assignable" offered a choice that could only ever fail.

get/api/permissions/assignable-roles

Headers

x-api-tokenstring nullable

Response

Successful Response

{"stackTrail":"paths:/api/permissions/assignable-roles:get:responses:200:content:application/json:schema","oasType":"schema","type":"unknown"}

Changes