---
title: "List roles that can be assigned by company admins"
method: GET
path: "/api/permissions/assignable-roles"
tags: ["Permissions"]
---

# List roles that can be assigned by company admins

`GET /api/permissions/assignable-roles`

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_ASSIGNABLE` — `system_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.

## Headers

- `x-api-token` string, nullable

## Response `200`

Successful Response

- unknown

## Other responses

- `422` — Validation Error

---

[API](https://skmtc.dev/multigest/apis/multigest-protected-api.md) · [All operations](https://skmtc.dev/multigest/apis/multigest-protected-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/multigest/multigest-protected-api/revisions/4b44eb76b3ee/schema)
