feat: account menu, database roles, and single logout

Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
This commit is contained in:
Daniel Noulet 2026-10-05 19:18:07 +02:00
parent 3fdc8cb489
commit c7c81fb65c
43 changed files with 2248 additions and 298 deletions

View file

@ -126,19 +126,38 @@ Both `/connect/token` and `/connect/userinfo` return the caller's `role`, `permi
```json
{
"sub": "9f0f...",
"id": "9f0f...",
"email": "ada@example.com",
"display_name": "Ada Lovelace",
"avatar_url": "https://loose.example.com/api/v1/avatars/kZ3...",
"tenant_id": "2b41...",
"tenant_slug": "nmbs",
"application_id": "7c02...",
"application": "guess",
"role": "developer",
"permissions": ["console.read", "profile.edit", "applications.manage", "keys.manage", "contracts.manage"],
"can": {"contracts.manage": true, "users.manage": false}
"permissions": [
"console.read",
"users.read",
"applications.manage",
"keys.manage",
"contracts.manage",
"users.manage",
"profile.edit"
]
}
```
`avatar_url` is public by design, so it can be embedded directly with a plain image tag. It is an empty string when the user has not uploaded a picture. Read `can` rather than checking `role` by name; the client package builds it from the same matrix the console uses. See [roles and capabilities](roles.md).
There is no `can` object on the wire. `avatar_url` is public by design, so it can be embedded directly with a plain image tag; it is an empty string when the user has not uploaded a picture.
A user with no membership in the application's tenant still authenticates, but the role falls back to `user` with only `profile.edit`.
Read `permissions` rather than testing `role` by name, because a tenant may define its own roles. The client package exposes the same check as a method:
```go
if user.Can("contracts.manage") {
// the role behind this session grants it
}
```
A user with no membership in the application's tenant still authenticates, but falls back to the built-in `user` role, whose only permission is `profile.edit`. See [roles, permissions, and profiles](roles.md).
## Security and availability
@ -147,8 +166,43 @@ A user with no membership in the application's tenant still authenticates, but t
- Do not replace the middleware's fail-closed behavior with an allow-on-error fallback.
- Apply CSRF protection to state-changing routes in the client application; the package handles login `state`, not your application forms.
- The middleware checks Go Loose on every request for immediate revocation. Deploy Go Loose highly available and use a bounded HTTP timeout.
- Signing out in Go Loose revokes every hosted-login session the user holds here, so
the client applications stop accepting their tokens on the next request. See
[Signing out across the family](#signing-out-across-the-family).
- API-key authentication and browser-user authentication are independent. Use `client.Middleware` for service/API keys and `BrowserAuth.Middleware` for logged-in users.
## Signing out across the family
Applications in this family share one identity, so signing out has to mean more than
clearing one cookie. `POST /auth/logout` does three things, in this order:
1. **Revokes every hosted-login session the user holds.** Go Loose issues the bearer
tokens these applications authenticate with, so this is what actually signs them
out: their tokens stop resolving immediately, without the application being told
or having to ask. This step reads the session first, which is why it happens before
the cookie is cleared.
2. **Clears the local management cookies** on the identity domain.
3. **Sends the browser to the identity provider's logout endpoint**, when the provider
advertises one in its discovery document, so the single sign-on session ends too.
Two consequences worth designing around:
- **A client application is signed out on its next request, not by being notified.**
Its own session cookie still exists; the bearer token inside it has been revoked. An
application that only checks the session locally will appear signed in until its
next call to Go Loose, at which point it should treat the rejection as a sign-out and
return the browser to `/login`.
- **Applications that authenticate against a different provider are not covered.**
The provider endpoint is read from the issuer Go Loose itself is configured with, so
this covers the applications that sign in through Go Loose. One pointed at a separate
identity provider needs its own end-session redirect.
A provider that does not advertise `end_session_endpoint` is not an error: step 3 is
skipped and the user lands back on the sign-in page, having still been signed out
locally and revoked. Go Loose sends `client_id` and `post_logout_redirect_uri` rather
than an `id_token_hint`, because a hint would mean keeping a live credential in the
browser cookie between login and logout.
## Applications on the local platform
The local platform signs in two client applications, both with the wildcard

View file

@ -8,7 +8,7 @@
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
- Return explicit errors. Runtime authorization must fail closed.
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
- Derive authorization from the matrix in `internal/role` instead of hardcoding role names. Pass `role.Grants(...)` into SQL, use `role.Allows` in handlers, and render controls from the capability lists the server returns.
- Derive authorization from the database role and permission tables instead of hardcoding role names. Resolve a member's capabilities by joining `role_permissions`, render controls from the capability lists the server returns, and keep `internal/role` limited to the permission vocabulary shared with the migrations.
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.

View file

@ -1,88 +1,91 @@
# Roles and capabilities
# Roles, permissions, and profiles
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.
Authorization is database-driven. Permissions and roles are rows, not Go constants, so a tenant can define its own vocabulary without a code change or a migration.
```
roles one row per role. tenant_id NULL means built-in.
role_permissions (role_id, permission) — what the role grants.
memberships.role_id → roles.id, NOT NULL, ON DELETE RESTRICT
```
## Data model
## Built-in capability matrix
- `permissions` is the closed vocabulary. Every row carries a `name`, a `label`, a `description`, and an `is_system` flag. System permissions cannot be edited or deleted.
- `roles` holds the roles. A `NULL tenant_id` marks a built-in role that every tenant sees and nobody may modify. A custom role belongs to exactly one tenant.
- `role_permissions` joins roles to permissions.
- `memberships.role_id` points at a role row. The older `memberships.role` text column was dropped in `0006_database_roles.sql`.
This is what the migration seeds. It is data, not code: change it with a new migration, not by editing Go.
Nine permissions ship as system rows:
| Permission | Label |
| --- | --- |
| `tenants.manage` | Manage tenants |
| `roles.manage` | Manage roles |
| `users.manage` | Manage members |
| `applications.manage` | Manage applications |
| `keys.manage` | Manage API keys |
| `contracts.manage` | Manage contracts |
| `users.read` | Read members |
| `console.read` | Read console |
| `profile.edit` | Edit own profile |
A tenant may add further permission names. They are grantable and reported through `/connect/userinfo`, but the console does not enforce them.
## Built-in roles
`0006_database_roles.sql` seeds these six roles. Note that `developer` and `operator` hold `users.manage`: any tenant member who runs the console can maintain their own tenant's members. Minting roles stays with `owner` and `admin` through `roles.manage`, because defining privilege is the operation that actually escalates it.
| Permission | owner | admin | developer | operator | viewer | user |
| --- | --- | --- | --- | --- | --- | --- |
| `tenants.manage` | yes | no | no | no | no | no |
| `roles.manage` | yes | yes | no | no | no | no |
| `users.manage` | yes | yes | yes | yes | no | no |
| `console.read` | yes | yes | yes | yes | yes | no |
| `users.read` | yes | yes | yes | yes | yes | no |
| `applications.manage` | yes | yes | yes | no | no | no |
| `keys.manage` | yes | yes | yes | yes | no | no |
| `contracts.manage` | yes | yes | yes | yes | no | no |
| `users.read` | yes | yes | yes | yes | yes | no |
| `console.read` | yes | yes | yes | yes | yes | no |
| `users.manage` | yes | yes | yes | yes | no | no |
| `roles.manage` | yes | yes | no | no | no | no |
| `tenants.manage` | yes | no | no | no | no | no |
| `profile.edit` | yes | yes | yes | yes | yes | yes |
Intended use:
`user` members have no `console.read`, so a session that lands on `/` is redirected to `/profile`. A `user` with no membership at all still sees the waiting room.
- **owner** — full control of the tenant, including defining roles and handing them out.
- **admin** — manages applications, keys, contracts, members, and roles.
- **developer** — ships applications, keys, and contracts, and maintains the tenant's members.
- **operator** — runs existing applications and maintains the tenant's members.
- **viewer** — read-only console access.
- **user** — self-service only. No console access; the session lands on `/profile`.
## Managing roles
`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.
`/roles` is the console page. The API is:
`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`.
| Route | Purpose |
| --- | --- |
| `GET /api/v1/roles` | List roles with their permissions and member counts |
| `POST /api/v1/roles` | Create a custom role for one tenant |
| `PUT /api/v1/roles/{id}` | Rename a role or change its permissions |
| `DELETE /api/v1/roles/{id}` | Delete a custom role |
| `GET /api/v1/permissions` | List the permission catalog |
| `POST /api/v1/permissions` | Add a permission to the catalog |
| `DELETE /api/v1/permissions/{name}` | Remove an unused permission |
## Tenant-defined roles
Two rules keep this from becoming a privilege-escalation hole:
`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.
- **Assignability is a subset rule.** A role is offered as `assignable` only when every permission it grants is one the requesting administrator already holds. `ListRoles` computes this per caller, so managing members can never hand out more authority than the administrator has.
- **Roles in use cannot be deleted.** `memberships.role_id` is `ON DELETE RESTRICT` and `DeleteRole` returns `ErrRoleInUse`, so a role cannot be pulled out from under somebody. Built-in roles are likewise undeletable.
Three rules keep this from becoming a privilege-escalation hole:
`role_permissions.permission` is a foreign key onto `permissions.name`, so a role cannot reference a capability that is not in the catalog.
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.
## Go side
Built-in roles are read only: the store refuses to edit or delete a row with `is_system` set.
`internal/role` holds only the permission vocabulary used to build the seed data and to keep the Go and SQL spellings in step. It intentionally has no role-to-permission matrix: that mapping lives in the database, so queries resolve a member's real capabilities with a join on `role_permissions` rather than against a hardcoded list.
## Where the checks live
Tenant-scoped statements resolve the grant through `role_permissions` using one shared predicate, `permissionPredicate` in `internal/store/roles.go`:
```sql
EXISTS (
SELECT 1
FROM memberships scoped
JOIN role_permissions granted ON granted.role_id = scoped.role_id
WHERE scoped.tenant_id = $1 AND scoped.user_id = $2
AND granted.permission = $3
)
```
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`.
Store methods enforce capabilities in SQL inside the same transaction as the write, so a request cannot pass a handler check and then fail, or the reverse.
## Display names and profile pictures
Every management session can open `/profile` and set a display name and a profile picture, independent of tenant role.
Every management session can open `/profile` and set a display name and a 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.
Setting the display name sets `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:
Pictures are stored as bytes in PostgreSQL under 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:
```html
<img src="https://loose.example.com/api/v1/avatars/kZ3..." alt="Ada Lovelace" width="64" height="64">
```
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.
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
## Clients
`/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.
`/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 vocabulary.
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`.
`avatar_url` is public by design and is an empty string when no picture is set. A user with no membership in the application's tenant still authenticates and falls back to the built-in `user` role, whose only permission is `profile.edit`.