Skip to main content
Every resource in VoicEra belongs to an organisation. A user can belong to several, holds a role in each, and works inside exactly one at a time — the active organisation, carried in the JWT.

The three entities

Memberships is the join: one document per (user, organisation) pair, carrying the role. A user with no membership in an organisation cannot see anything inside it.

Roles

Three roles, defined as a Literal in apps/api/app/models/schemas.py:

Permission matrix

Role checks are explicit HTTPException raises inside the handlers, not FastAPI dependencies. A valid token with the wrong role gets 403; a missing or invalid token gets 401.

The active organisation

A JWT carries three claims that matter: Handlers read org_id from the token, never from the request body, so a caller cannot reach another tenant’s data by changing a payload field. Signing uses HS256 with SECRET_KEY, and tokens expire after ACCESS_TOKEN_EXPIRE_MINUTES (default 30).
If SECRET_KEY is unset, apps/api/app/auth.py logs a warning and generates a temporary key at import time. Every restart then invalidates all outstanding tokens, and multiple replicas will not accept each other’s. Always set it — make application-up does this for you.

Switching organisations

You get back a new token scoped to that organisation, with the role you hold there. The choice also persists as your default for the next login. List what you can switch to with GET /api/v1/users/organisations.

Signing up and inviting

Signup always creates an organisation and makes the signer its super_admin. There is no seeded default account — see Generated secrets and defaults. Check whether an address is already known before inviting:

Machine access

The runtime and other services are not users, so they do not log in. They exchange the shared internal key for a short-lived, organisation-scoped token: The returned token carries role admin in the requested organisation. An unknown org_id returns 404.
INTERNAL_API_KEY is a single shared secret with organisation-wide reach. Treat it like a root credential: never send it from a browser, and rotate it as described in Security hardening.

How scoping is enforced

  1. The token is verified and decoded (get_current_user).
  2. org_id comes from the token.
  3. Queries filter on that org_id.
  4. Role-restricted handlers compare the role claim and raise 403 on mismatch.
A request for an object in another organisation returns 404, not 403 — existence is not leaked across tenants.