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:
parent
3fdc8cb489
commit
c7c81fb65c
43 changed files with 2248 additions and 298 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ./...`.
|
||||
|
||||
|
|
|
|||
111
docs/roles.md
111
docs/roles.md
|
|
@ -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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue