A role is a named bundle of permissions. Roles are rows: six built-in ones seeded by migration `0006_database_roles.sql`, plus whatever each tenant defines for itself. `internal/role` no longer holds a matrix — it holds the closed vocabulary of permissions a role may be granted, and the database resolves what a membership actually grants.
`developer` and `operator` hold `users.manage` so that anyone who runs the console can maintain their own tenant's members. `roles.manage` deliberately stays narrow: defining roles is what defines privilege, so it is not something a member administrator can grant themselves.
`user` members without any tenant membership still see the waiting room. Those with a membership are redirected from `/` to `/profile`, because the dashboard requires `console.read`.
`POST /api/v1/roles` creates a role for one tenant. It is visible only in that tenant, and the `/roles` screen lists it beside the built-ins with its permission chips.
Three rules keep this from becoming a privilege-escalation hole:
1.**Roles are scoped.**`roles.tenant_id` is set, and every query that resolves a role filters on the caller's own tenant, so a role id cannot be probed across tenants.
2.**You cannot mint what you do not hold.** Creating or editing a role requires `roles.manage`*and* requires every permission in the role to be one the caller already has. Assigning a role enforces the same subset rule.
3.**A role in use cannot be deleted.**`memberships.role_id` is `ON DELETE RESTRICT`, and `DELETE /api/v1/roles/{id}` reports 409 until the members are moved. Built-in roles are immutable and report 404.
Built-in roles are read only: the store refuses to edit or delete a row with `is_system` set.
Because the check asks for a permission rather than a role name, a tenant's own role works everywhere a built-in one does without any Go change. The server never decides authorization: `handleStoreError` maps `store.ErrNotFound` to a 404 so a denied write and a missing row are indistinguishable.
`role.Permission` names are also constrained by a check constraint on `role_permissions.permission`, so an unknown capability cannot be stored even if a caller bypasses the Go layer. **Adding a permission therefore needs three edits**: the constant in `internal/role/role.go`, the check constraint and seed in a new migration, and the expected grants in `internal/store/roles_integration_test.go`.
Every management session can open `/profile` and set a display name and a profile picture, independent of tenant role.
Setting the display name flags the user as `name_customized`, which stops the identity provider from overwriting it on the next login. The row is still matched by email on every login, so customization never breaks sign-in.
Pictures are stored as bytes in PostgreSQL with a random 256-bit key and served from the public `GET /api/v1/avatars/{key}` route. The URL is unguessable and carries no user identity, so applications can embed it directly:
Accepted formats are JPEG, PNG, and GIF up to 2 MiB and 4096 by 4096 pixels. Replacing a picture issues a new key and retires the old one, so cached images never go stale and revoked URLs stop resolving.
## Clients and permissions
`/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application. `client.User.Can` exposes the same checks to Go code, so a client can adapt its UI without duplicating the matrix.
A user with no membership in the application's tenant still authenticates, but the role falls back to the built-in `user` role with only `profile.edit`.