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

32
.air.toml Normal file
View file

@ -0,0 +1,32 @@
# Air watches this tree and rebuilds into ./dist on every change.
# Used by `make compose-dev-up` (docker-compose.dev.yaml + Dockerfile.dev).
# Docs: https://github.com/air-verse/air/blob/master/air_example.toml
root = "/src"
tmp_dir = "/tmp/air"
log_file = "/tmp/air/build-errors.log"
delay = 300
stop_on_error = true
stop_on_root_dir = true
send_interrupt = true
kill_delay = "2s"
log_level = "info"
[build]
# Output lands in ./dist on the host through the bind mount.
bin = "dist/go-loose"
cmd = "go build -trimpath -o ./dist/go-loose ./cmd/go-loose"
args_bin = []
# web/ is embedded with go:embed, so asset edits need a rebuild too.
include_ext = ["go", "html", "js", "css", "sql", "json", "yaml", "yml", "toml"]
include_dir = ["cmd", "internal", "client", "web"]
# data/db is the PostgreSQL volume and would otherwise trigger rebuilds.
exclude_dir = [".git", "dist", "data", "docs", "node_modules", "tmp"]
exclude_regex = ["_test\\.go$"]
exclude_unchanged = true
follow_symlink = true
# Bind mounts do not deliver inotify events on Docker Desktop, so poll there.
[misc]
poll = false
poll_interval = 300

View file

@ -4,4 +4,6 @@ bin
coverage.out
google_secrets.json
data
_data-old-bind-mount
dist
tmp

2
.gitignore vendored
View file

@ -6,3 +6,5 @@ coverage.out
google_secrets.json
data/
_data-old-bind-mount/
dist/

21
Dockerfile.dev Normal file
View file

@ -0,0 +1,21 @@
ARG GO_VERSION=1.27.1
ARG AIR_VERSION=v1.67.4
FROM golang:${GO_VERSION}-alpine
ARG AIR_VERSION
RUN apk add --no-cache ca-certificates git \
&& go install github.com/air-verse/air@${AIR_VERSION}
ENV AIR_VERSION=${AIR_VERSION}
ENV CGO_ENABLED=0
ENV GOCACHE=/tmp/go-build
ENV GOMODCACHE=/go/pkg/mod
ENV GOFLAGS=-buildvcs=false
WORKDIR /src
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]

View file

@ -10,10 +10,11 @@ GO_LOOSE_DEV_USER ?= admin@local.test
GO_LOOSE_BOOTSTRAP_TENANT ?= nmbs
GO_LOOSE_BOOTSTRAP_APP ?= guess
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true
GO_LOOSE_DEV_PORT ?= 8080
export
.PHONY: build test integration-test run compose-up compose-down fmt vet
.PHONY: build test integration-db integration-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet
build:
go build ./...
@ -21,11 +22,25 @@ build:
test:
go test -race ./...
# Integration tests truncate every table in the target database, so point them at
# a scratch database rather than one holding real data.
# Integration tests drop every table they touch, so they only run against scratch
# databases. Both test helpers refuse any database whose name lacks "test", and
# `make integration-db` creates the two they expect. Override the check with
# GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 only when you mean it.
#
# The store-backed tests in internal/server need a database of their own: Go runs
# package tests in parallel and both packages drop every table, so sharing one
# database makes them fight over the schema.
GO_LOOSE_TEST_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_test?sslmode=disable
GO_LOOSE_TEST_SERVER_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_server_test?sslmode=disable
# createdb is not idempotent, so ignore a database that is already there.
integration-db:
-docker compose exec -T postgres createdb -U goloose goloose_test
-docker compose exec -T postgres createdb -U goloose goloose_server_test
integration-test:
@test -n "$(GO_LOOSE_TEST_DATABASE_URL)" || { echo "set GO_LOOSE_TEST_DATABASE_URL to a scratch database"; exit 1; }
go test -race ./internal/store/ -count=1
go test -race ./internal/store/ ./internal/server/ -count=1
run:
go run ./cmd/go-loose
@ -40,8 +55,25 @@ compose-up:
compose-down:
docker compose down
# Air rebuilds into ./dist on every change.
compose-dev-up:
mkdir -p dist
docker compose -f docker-compose.dev.yaml up --build
compose-dev-down:
docker compose -f docker-compose.dev.yaml down --remove-orphans
# Air writes dist/go-loose as root inside the container, so the host user
# cannot delete it directly. Empty the directory through a container instead.
-docker run --rm -v "$(CURDIR)/dist:/dist" alpine sh -c 'rm -rf /dist/*' 2>/dev/null || true
compose-dev-logs:
docker compose -f docker-compose.dev.yaml logs -f go-loose
fmt:
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
# Prune directories that are not source and may be unreadable: vendored
# code, build output, the PostgreSQL volume, and anything hidden or
# underscore prefixed, which is how Go itself skips package trees.
gofmt -w $$(find . -mindepth 1 -type d \( -name vendor -o -name dist -o -name '.*' -o -name '_*' \) -prune -o -name '*.go' -print)
vet:
go vet ./...

View file

@ -83,5 +83,28 @@ Development requires Go 1.27.1 or newer.
Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one.
`make integration-test` runs the store integration tests against `GO_LOOSE_TEST_DATABASE_URL`. Those tests drop every table in the target database, so point them at a scratch database.
`make integration-db` creates the two scratch databases the database-backed tests use, and `make integration-test` runs them. Those tests drop every table they touch, so they refuse to run against a database whose name does not contain `test`. That guard exists because pointing them at a development database destroys it.
### Hot reload
`make compose-dev-up` starts a development stack that runs Go Loose under [Air](https://air-verse.github.io/). Air watches `cmd`, `internal`, `client`, and `web`, rebuilds on every change, and writes the binary to `./dist`. Changes to `web/` are embedded at build time, so editing a template, stylesheet, or script triggers a rebuild too. The server restarts in place and `/healthz` confirms it came back.
```bash
make compose-dev-up # build the image, start postgres and Air
make compose-dev-logs # follow the rebuild and request logs
make compose-dev-down # stop and remove the stack
```
| Variable | Default | Purpose |
| --- | --- | --- |
| `GO_LOOSE_DEV_PORT` | `8080` | Host port for the dev server |
| `GO_LOOSE_POSTGRES_PORT` | `5433` | Host port for PostgreSQL |
The dev stack binds mounts the source tree, so Air writes `dist/go-loose` as `root` inside the container. On Linux, delete it with `sudo rm dist/go-loose` if the file ownership gets in the way.
`docker-compose.yaml` remains the stack that matches production: a distroless image, no dev login, and the `local-dev-edge` network. `docker-compose.dev.yaml` and `Dockerfile.dev` are development-only.
The database is not bind mounted into the source tree: PostgreSQL stores its files as `0700` owned by its own user, which would make `go build ./...` fail to walk the directory. `docker-compose.yaml` uses the `db-data` volume and `docker-compose.dev.yaml` uses a separate `db-data-dev`, so `docker compose -f docker-compose.dev.yaml down -v` cannot reach the real database. Reach a database through the published `5433` port or `docker compose exec postgres psql -U goloose -d goloose`.
The dev database starts empty and migrations rebuild it on boot. `make compose-dev-down` also empties `./dist`, because Air writes that binary as `root` inside the container.

87
docker-compose.dev.yaml Normal file
View file

@ -0,0 +1,87 @@
# Developer stack: docker compose -f docker-compose.dev.yaml up --build
#
# Runs Go Loose with Air for hot reload. Air rebuilds on every source change and
# writes the binary to ./dist, which is bind mounted so you can inspect or run it
# from the host. Only postgres is published; Go Loose is reached through the
# proxy or with `docker compose ... exec`.
#
# The production-shaped compose file stays at docker-compose.yaml.
services:
postgres:
image: postgres:17-alpine
ports:
- "${GO_LOOSE_POSTGRES_PORT:-5433}:5432"
environment:
POSTGRES_DB: goloose
POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose
volumes:
# Deliberately not the volume docker-compose.yaml uses: `down -v` on this
# file would otherwise delete the real database. The dev database is
# expendable and starts empty.
- db-data-dev:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
interval: 5s
timeout: 3s
retries: 10
networks:
- go_loose_dev_network
go-loose:
build:
context: .
dockerfile: Dockerfile.dev
depends_on:
postgres:
condition: service_healthy
environment:
GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-http://localhost:8080}"
GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-localhost}"
GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable"
GO_LOOSE_SESSION_SECRET: "${GO_LOOSE_SESSION_SECRET:-local-development-secret-change-me-now}"
# Dev login skips OIDC so you can work without Google credentials.
GO_LOOSE_DEV_LOGIN: "${GO_LOOSE_DEV_LOGIN:-true}"
GO_LOOSE_DEV_USER: "${GO_LOOSE_DEV_USER:-admin@local.test}"
GO_LOOSE_LOCAL_LOGIN: "${GO_LOOSE_LOCAL_LOGIN:-true}"
GO_LOOSE_LOCAL_ADMIN_EMAIL: "${GO_LOOSE_LOCAL_ADMIN_EMAIL:-admin@goloose.local}"
GO_LOOSE_LOCAL_ADMIN_PASSWORD: "${GO_LOOSE_LOCAL_ADMIN_PASSWORD:-}"
GO_LOOSE_ALLOWED_EMAIL_DOMAINS: "${GO_LOOSE_ALLOWED_EMAIL_DOMAINS:-}"
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "${GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS:-true}"
GO_LOOSE_BOOTSTRAP_TENANT: "${GO_LOOSE_BOOTSTRAP_TENANT:-nmbs}"
GO_LOOSE_BOOTSTRAP_APP: "${GO_LOOSE_BOOTSTRAP_APP:-guess}"
GO_LOOSE_OIDC_ISSUER: "${GO_LOOSE_OIDC_ISSUER:-}"
GO_LOOSE_OIDC_CLIENT_ID: "${GO_LOOSE_OIDC_CLIENT_ID:-}"
GO_LOOSE_OIDC_CLIENT_SECRET: "${GO_LOOSE_OIDC_CLIENT_SECRET:-}"
GO_LOOSE_OIDC_REDIRECT_URL: "${GO_LOOSE_OIDC_REDIRECT_URL:-}"
volumes:
# Source is mounted read-write so Air can write ./dist. Use
# :ro here if you only want the host copy of dist to be authoritative.
- ./:/src:cached
# The build output, per the Air config above.
- ./dist:/src/dist
# Keep build artifacts and the module cache out of the image layer.
- air-tmp:/tmp/air
- go-build-cache:/tmp/go-build
- go-mod-cache:/go/pkg/mod
ports:
- "${GO_LOOSE_DEV_PORT:-8080}:8080"
healthcheck:
test: ["CMD", "/src/dist/go-loose", "-healthcheck"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
init: true
networks:
- go_loose_dev_network
networks:
go_loose_dev_network:
volumes:
db-data-dev:
air-tmp:
go-build-cache:
go-mod-cache:

View file

@ -8,7 +8,9 @@ services:
POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose
volumes:
- ./data/db:/var/lib/postgresql/data
# A named volume keeps PostgreSQL's 0700 permissions away from the source
# tree; a host bind mount here makes `go build ./...` fail to walk data/.
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
interval: 5s
@ -48,6 +50,9 @@ services:
aliases:
- go-loose
volumes:
db-data:
networks:
go_loose_network:
local_dev_edge:

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`.

View file

@ -29,6 +29,12 @@ type Config struct {
BootstrapTenant string
BootstrapApp string
HTTPTimeout time.Duration
// ThemeBaseURL is the go-bananas runtime origin supplying the shared styling
// contract. Empty means this application keeps its own hand-written theme
// blocks, which is the safe default: a missing contract must degrade to the
// application's existing look, not to an unstyled page.
ThemeBaseURL string
}
func Load() (Config, error) {
@ -47,6 +53,9 @@ func Load() (Config, error) {
OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"),
OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"),
// Trailing slashes are trimmed because every use is path concatenation, and a
// double slash would produce a URL the service answers 404 for.
ThemeBaseURL: strings.TrimRight(os.Getenv("GO_LOOSE_THEME_BASE_URL"), "/"),
OIDCRedirectURL: env("GO_LOOSE_OIDC_REDIRECT_URL", baseURL+"/auth/callback"),
AllowedEmailDomains: csv(os.Getenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS")),
AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false),

View file

@ -0,0 +1,47 @@
-- +goose Up
-- Permissions become rows so the catalog can be extended without a migration.
-- The nine names the application enforces in SQL are seeded here; a tenant may
-- add further names, which are grantable and reported through /connect/userinfo
-- but are not checked by the console itself.
CREATE TABLE permissions (
name text PRIMARY KEY CHECK (name ~ '^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$'),
label text NOT NULL CHECK (length(btrim(label)) BETWEEN 1 AND 100),
description text NOT NULL DEFAULT '',
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO permissions (name, label, description, is_system) VALUES
('tenants.manage', 'Manage tenants', 'Create tenants and administer the installation.', true),
('roles.manage', 'Manage roles', 'Create and edit the roles of this tenant.', true),
('users.manage', 'Manage members', 'Invite members and change their roles and application grants.', true),
('applications.manage', 'Manage applications', 'Register applications and configure client credentials.', true),
('keys.manage', 'Manage API keys', 'Issue and revoke API keys.', true),
('contracts.manage', 'Manage contracts', 'Import OpenAPI contracts and their endpoints.', true),
('users.read', 'Read members', 'List the members of a tenant and the roles they hold.', true),
('console.read', 'Read console', 'Open the management console and pick tenants.', true),
('profile.edit', 'Edit own profile', 'Change one''s own display name and picture.', true);
-- The closed list becomes a foreign key, so a role can no longer be granted a
-- capability that is not in the catalog. Granting one is now a matter of
-- inserting the permission first rather than editing a check constraint.
ALTER TABLE role_permissions DROP CONSTRAINT role_permissions_permission_check;
ALTER TABLE role_permissions
ADD CONSTRAINT role_permissions_permission_fkey
FOREIGN KEY (permission) REFERENCES permissions(name) ON DELETE RESTRICT;
-- +goose Down
DELETE FROM role_permissions WHERE permission NOT IN (
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
);
ALTER TABLE role_permissions DROP CONSTRAINT role_permissions_permission_fkey;
ALTER TABLE role_permissions
ADD CONSTRAINT role_permissions_permission_check CHECK (permission IN (
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
));
DROP TABLE permissions;

View file

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE installation_settings ADD COLUMN oidc_redirect_url text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE installation_settings DROP COLUMN IF EXISTS oidc_redirect_url;

View file

@ -0,0 +1,366 @@
package server
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
neturl "net/url"
"os"
"strings"
"testing"
"golang.org/x/oauth2"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
)
// logoutServer builds a server with a session manager so logout can be exercised
// end to end. The store is nil, so the client-session revocation is expected to
// panic-free no-op only where it is guarded; these tests therefore exercise the
// redirect behaviour, which is what a person sees.
func logoutServer(t *testing.T) *Server {
t.Helper()
secret := strings.Repeat("s", 32)
return &Server{
cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"},
sessions: session.New(secret, false),
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
// TestLogoutClearsTheSessionAndReturnsToSignIn is the baseline: with no identity
// provider logout endpoint configured, signing out lands back on the sign-in page.
func TestLogoutClearsTheSessionAndReturnsToSignIn(t *testing.T) {
server := logoutServer(t)
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("location = %q, want /login", location)
}
var cleared bool
for _, cookie := range response.Result().Cookies() {
if cookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("logout did not clear the session cookie")
}
}
// TestLogoutEndsTheProviderSessionWhenAdvertised covers the cross-application half:
// when the provider publishes an end_session_endpoint the browser is sent there, so
// the single sign-on session the other applications share is ended too.
func TestLogoutEndsTheProviderSessionWhenAdvertised(t *testing.T) {
server := logoutServer(t)
server.endSession = "https://idp.example.test/protocol/openid-connect/logout"
server.oauth = &oauth2ConfigStub
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
location := response.Header().Get("Location")
if !strings.HasPrefix(location, server.endSession) {
t.Fatalf("location = %q, want the provider logout endpoint", location)
}
for _, want := range []string{
"client_id=go-loose",
"post_logout_redirect_uri=https%3A%2F%2Fauth.example.test%2Flogin",
} {
if !strings.Contains(location, want) {
t.Errorf("location = %q, want it to contain %q", location, want)
}
}
}
// TestLogoutSkipsTheProviderWhenItIsUnconfigured documents the fallback: a
// deployment whose provider does not advertise a logout endpoint still signs the
// user out locally rather than erroring.
func TestLogoutSkipsTheProviderWhenItIsUnconfigured(t *testing.T) {
for name, configure := range map[string]func(*Server){
"no endpoint": func(s *Server) {},
"no client id": func(s *Server) { s.endSession = "https://idp.example.test/logout" },
"unparseable": func(s *Server) { s.endSession = "://nope"; s.oauth = &oauth2ConfigStub },
"nothing set up": func(s *Server) { s.endSession = "" },
} {
t.Run(name, func(t *testing.T) {
server := logoutServer(t)
configure(server)
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("location = %q, want the local fallback /login", location)
}
})
}
}
// TestAccountMenuIsEmbedded guards the component: it is mounted by every page, so a
// page that forgets the placeholder loses the whole control.
func TestAccountMenuIsEmbedded(t *testing.T) {
for _, page := range []string{"index.html", "profile.html", "roles.html"} {
t.Run(page, func(t *testing.T) {
content, err := web.Files.ReadFile(page)
if err != nil {
t.Fatalf("%s must be embedded: %v", page, err)
}
text := string(content)
for _, marker := range []string{"data-account-menu", "/assets/account.js"} {
if !strings.Contains(text, marker) {
t.Errorf("%s is missing %q", page, marker)
}
}
// The account control replaces the separate sign-out button, so the
// page must not still carry one or the header shows two of each.
if strings.Contains(text, `<form action="/auth/logout"`) {
t.Errorf("%s still renders its own logout form", page)
}
if strings.Contains(text, `id="identity"`) {
t.Errorf("%s still renders the old identity chip", page)
}
})
}
}
func TestAccountMenuScriptIsServed(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}}
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/account.js", nil)
request.SetPathValue("name", "account.js")
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
body := response.Body.String()
// The sign-out item has to be a form post, not a fetch, so that signing out
// still works with scripting unavailable.
if !strings.Contains(body, `method="post"`) || !strings.Contains(body, "/auth/logout") {
t.Error("the account menu must post to /auth/logout to sign out")
}
}
// TestRowActionsDoNotUseCardStyling pins the table fix. .card-action is
// width:100% with a 16px top margin for the foot of a card, which made two buttons
// stacked in a narrow cell taller than the rest of the row.
func TestRowActionsDoNotUseCardStyling(t *testing.T) {
for _, page := range []string{"app.js", "roles.js"} {
content, err := web.Files.ReadFile(page)
if err != nil {
t.Fatalf("%s must be embedded: %v", page, err)
}
if strings.Contains(string(content), "table-actions") == false {
t.Errorf("%s does not use the table action wrapper", page)
}
}
css, err := web.Files.ReadFile("style.css")
if err != nil {
t.Fatalf("style.css must be embedded: %v", err)
}
if !strings.Contains(string(css), ".table-action{") {
t.Error("style.css has no .table-action rule, so row buttons would be unstyled")
}
}
// oauth2ConfigStub carries the client id logout needs without reaching a provider.
var oauth2ConfigStub = oauth2.Config{ClientID: "go-loose"}
// TestLogoutRevokesHostedLoginSessions is the test that matters for cross-application
// sign-out. Go Loose issues the bearer tokens the tenant applications authenticate
// with, so revoking them here is what makes those applications reject the user
// without ever being told. Both halves need real rows: a real session cookie and a
// real client session.
func TestLogoutRevokesHostedLoginSessions(t *testing.T) {
ctx := t.Context()
// The store is opened over PostgreSQL, which is what this project uses; there is
// no in-memory engine to substitute. The test skips when none is reachable rather
// than failing, so the rest of the suite still runs without a database.
db, err := database.Open(ctx, testDatabaseURL(t))
if err != nil {
t.Skipf("no test database available: %v", err)
}
defer db.Close()
if err := resetSchema(ctx, db); err != nil {
t.Fatalf("reset schema: %v", err)
}
if err := database.Migrate(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
st := store.New(db)
// A tenant with an application the owner can sign in to, and a live hosted-login
// session issued from it.
owner, err := st.UpsertUser(ctx, "local:owner@example.test", "owner@example.test", "Owner")
if err != nil {
t.Fatalf("upsert owner: %v", err)
}
tenant, err := st.CreateTenant(ctx, owner.ID, "acme", "Acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
app, err := st.CreateApplication(ctx, owner.ID, store.Application{
TenantID: tenant.ID, Slug: "client", Name: "Client", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
secretHash := []byte("hashed-client-secret")
if _, err := st.ConfigureClient(ctx, owner.ID, app.ID, []string{"https://app.example.test/cb"}, secretHash); err != nil {
t.Fatalf("configure client: %v", err)
}
ownerRole, err := st.SystemRoleID(ctx, "owner")
if err != nil {
t.Fatalf("owner role: %v", err)
}
if err := st.SetUserAccess(ctx, owner.ID, tenant.ID, owner.ID, ownerRole, []string{app.ID}); err != nil {
t.Fatalf("grant application access: %v", err)
}
codeHash := []byte("hashed-authorization-code")
if err := st.CreateAuthorizationCode(ctx, app.ID, owner.ID, "https://app.example.test/cb", codeHash); err != nil {
t.Fatalf("create authorization code: %v", err)
}
tokenHash := []byte("hashed-session-token")
if _, _, err := st.ExchangeAuthorizationCode(ctx, app.ClientID, "https://app.example.test/cb", secretHash, codeHash, tokenHash); err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
before, err := st.CountActiveClientSessions(ctx, owner.ID)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if before != 1 {
t.Fatalf("active client sessions before logout = %d, want 1", before)
}
// A real session cookie, so the handler resolves an identity and takes the
// revocation path rather than the anonymous one.
secret := strings.Repeat("s", 32)
manager := session.New(secret, false)
server := &Server{
cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"},
store: st,
sessions: manager,
// The handler logs how many sessions it revoked, so a discard logger is
// needed rather than leaving it nil.
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
issued := httptest.NewRecorder()
if err := manager.Set(issued, session.Claims{UserID: owner.ID, Management: true}); err != nil {
t.Fatalf("issue session: %v", err)
}
var sessionCookie *http.Cookie
for _, cookie := range issued.Result().Cookies() {
if cookie.Name == "go_loose_session" {
sessionCookie = cookie
}
}
if sessionCookie == nil {
t.Fatal("no session cookie was issued")
}
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil)
request.AddCookie(sessionCookie)
response := httptest.NewRecorder()
server.authLogout(response, request)
after, err := st.CountActiveClientSessions(ctx, owner.ID)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if after != 0 {
t.Fatalf("active client sessions after logout = %d, want 0: the other applications would still accept these tokens", after)
}
// The token itself must stop resolving, which is what the tenant application
// experiences as being signed out.
if _, err := st.ClientUserByToken(ctx, tokenHash); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("token still resolves after logout: %v, want ErrNotFound", err)
}
}
// testDatabaseURL returns a database for the store-backed tests to reset and
// migrate.
//
// It reads its own variable rather than GO_LOOSE_TEST_DATABASE_URL, and defaults to
// a different database again. Go runs package tests in parallel and both packages
// drop every table, so pointing them at one database makes them fight over the
// schema; deriving this from the shared variable would reproduce exactly that.
func testDatabaseURL(t *testing.T) string {
url := envOr("GO_LOOSE_TEST_SERVER_DATABASE_URL",
"postgres://goloose:goloose@localhost:5433/goloose_server_test?sslmode=disable")
requireScratchDatabase(t, url)
return url
}
// requireScratchDatabase refuses to run against anything that looks like a real
// database, because resetSchema drops every table in the public schema. The
// database name must contain "test" unless the operator opts out explicitly.
func requireScratchDatabase(t *testing.T, rawURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" {
return
}
parsed, err := neturl.Parse(rawURL)
if err != nil {
t.Fatalf("parse test database URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run store-backed tests against database %q: resetSchema drops every table. "+
"Point it at a scratch database whose name contains \"test\" (run `make integration-db` to create one), "+
"or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name)
}
}
// envOr returns the environment value or a fallback.
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
// resetSchema drops every table so a store-backed test starts from the same state a
// fresh installation has. The tables live in a scratch database; the same helper
// exists in the store package's own tests.
func resetSchema(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
return err
}
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return err
}
tables = append(tables, name)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, name := range tables {
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS "`+name+`" CASCADE`); err != nil {
return err
}
}
return nil
}

View file

@ -27,10 +27,10 @@ type clientUserResponse struct {
Permissions []string `json:"permissions"`
}
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
func (s *Server) clientUser(r *http.Request, user store.ClientUser) clientUserResponse {
return clientUserResponse{
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
AvatarURL: s.avatarURL(r, user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: user.Permissions,
}
@ -124,7 +124,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(r, user),
})
}
@ -143,7 +143,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "load client user", err)
return
}
writeJSON(w, http.StatusOK, s.clientUser(user))
writeJSON(w, http.StatusOK, s.clientUser(r, user))
}
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {

View file

@ -12,8 +12,6 @@ import (
"github.com/danyel/go-loose/web"
)
const googleDemoClientID = "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com"
func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
if s.installed.Load() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
@ -31,7 +29,6 @@ func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
}
page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{GOOGLE_CLIENT_ID}}", googleDemoClientID,
"{{CALLBACK_URL}}", html.EscapeString(s.cfg.OIDCRedirectURL),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -56,38 +53,44 @@ func (s *Server) install(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "invalid installer form; reload the installer")
return
}
clientID := strings.TrimSpace(r.Form.Get("oidc_client_id"))
clientSecret := strings.TrimSpace(r.Form.Get("oidc_client_secret"))
if clientID == "" || clientSecret == "" {
writeError(w, http.StatusBadRequest, "Google client ID and client secret are required")
return
}
if err := s.configureOIDC(r.Context(), "https://accounts.google.com", clientID, clientSecret); err != nil {
writeError(w, http.StatusBadGateway, "Google OIDC discovery failed")
return
}
ciphertext, err := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if err != nil {
s.internalError(w, "encrypt OIDC secret", err)
issuer := strings.TrimSpace(r.Form.Get("field1"))
clientID := strings.TrimSpace(r.Form.Get("field2"))
clientSecret := strings.TrimSpace(r.Form.Get("field3"))
redirectURL := strings.TrimSpace(r.Form.Get("field4"))
if issuer == "" || clientID == "" || clientSecret == "" || redirectURL == "" {
writeError(w, http.StatusBadRequest, "All four fields are required")
return
}
seedDemo := r.Form.Get("seed_demo") == "on"
var demoUsers []store.DemoUser
var err error
if seedDemo {
demoUsers, err = buildDemoUsers()
if err != nil {
s.internalError(w, "hash demo credentials", err)
return
}
} else {
demoUsers = nil
}
ciphertext, encErr := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if encErr != nil {
s.internalError(w, "encrypt OIDC secret", encErr)
return
}
err = s.store.Install(r.Context(), store.Installation{
OIDCIssuer: "https://accounts.google.com", OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, SeededDemo: seedDemo,
OIDCIssuer: issuer, OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, OIDCRedirectURL: redirectURL, SeededDemo: seedDemo,
}, demoUsers)
if err != nil {
s.internalError(w, "complete installation", err)
return
}
// Configure OIDC in the running server
if err := s.configureOIDC(r.Context(), issuer, clientID, clientSecret, redirectURL); err != nil {
s.internalError(w, "configure OIDC", err)
return
}
s.installed.Store(true)
if err := s.ensureLocalAdministrator(r.Context()); err != nil {
s.internalError(w, "bootstrap local administrator", err)

View file

@ -9,23 +9,51 @@ import (
"golang.org/x/oauth2"
)
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret string) error {
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret, redirectURL string) error {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return fmt.Errorf("discover OIDC provider: %w", err)
}
config := &oauth2.Config{
ClientID: clientID, ClientSecret: clientSecret,
Endpoint: provider.Endpoint(), RedirectURL: s.cfg.OIDCRedirectURL,
Endpoint: provider.Endpoint(), RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
s.authMu.Lock()
s.oauth = config
s.verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
// The logout endpoint is not part of the OAuth endpoint set, so it is read out
// of the discovery document separately. A provider that does not advertise one
// leaves this empty, which is what makes signing out fall back to clearing the
// local session alone.
s.endSession = providerLogoutEndpoint(provider)
s.authMu.Unlock()
return nil
}
// providerLogoutEndpoint reads end_session_endpoint out of an OIDC discovery
// document, returning an empty string when the provider does not advertise one.
func providerLogoutEndpoint(provider *oidc.Provider) string {
var claims struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := provider.Claims(&claims); err != nil {
return ""
}
return claims.EndSessionEndpoint
}
// logoutConfiguration returns what is needed to end a session at the identity
// provider: the discovered endpoint and the client it would be requested for.
func (s *Server) logoutConfiguration() (endpoint, clientID string) {
s.authMu.RLock()
defer s.authMu.RUnlock()
if s.oauth != nil {
clientID = s.oauth.ClientID
}
return s.endSession, clientID
}
func (s *Server) oidcConfiguration() (*oauth2.Config, *oidc.IDTokenVerifier) {
s.authMu.RLock()
defer s.authMu.RUnlock()

View file

@ -0,0 +1,91 @@
package server
import (
"errors"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/store"
)
const maxPermissionLabel = 100
// listPermissions serves the whole catalog. Every management session may read it,
// because the role editor needs it to render its checkboxes.
func (s *Server) listPermissions(w http.ResponseWriter, r *http.Request) {
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": catalog})
}
// createPermission adds a name to the shared catalog so that roles can grant it.
//
// The catalog is installation-wide, so this is restricted to a system
// administrator or a holder of tenants.manage. Note what the new name does and
// does not do: it becomes grantable and is reported through /connect/userinfo,
// but nothing in the console checks for it, so it cannot unlock console
// behaviour. The response says so, and the UI repeats it.
func (s *Server) createPermission(w http.ResponseWriter, r *http.Request) {
var request struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &request) {
return
}
name := strings.TrimSpace(request.Name)
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest,
"name must be lowercase words separated by dots, for example reports.export")
return
}
label := strings.TrimSpace(request.Label)
if label == "" || len(label) > maxPermissionLabel {
writeError(w, http.StatusBadRequest, "label must be between 1 and 100 characters")
return
}
description := strings.TrimSpace(request.Description)
if len(description) > 500 {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
created, err := s.store.CreatePermission(
r.Context(), claimsFrom(r).UserID, name, label, description)
if err != nil {
switch {
case errors.Is(err, store.ErrPermissionTaken):
writeError(w, http.StatusConflict, "this permission already exists")
case errors.Is(err, store.ErrUnknownPermission):
writeError(w, http.StatusBadRequest, err.Error())
default:
s.handleStoreError(w, "create permission", err)
}
return
}
writeJSON(w, http.StatusCreated, created)
}
// deletePermission removes a catalog entry that no role grants.
func (s *Server) deletePermission(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest, "unknown permission name")
return
}
if err := s.store.DeletePermission(r.Context(), claimsFrom(r).UserID, name); err != nil {
switch {
case errors.Is(err, store.ErrPermissionInUse):
writeError(w, http.StatusConflict, "remove this permission from every role before deleting it")
case errors.Is(err, store.ErrPermissionSystem):
writeError(w, http.StatusConflict, "built-in permissions cannot be deleted")
default:
s.handleStoreError(w, "delete permission", err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -128,16 +128,36 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
func (s *Server) profileResponse(profile store.Profile) profileResponse {
return profileResponse{
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
AvatarURL: s.avatarURL(profile.AvatarKey), Memberships: profile.Memberships,
AvatarURL: s.avatarPath(profile.AvatarKey), Memberships: profile.Memberships,
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
}
}
func (s *Server) avatarURL(key *string) string {
// avatarPath returns the same-origin path of a stored profile picture.
//
// The picture is served by this application, so a page of this application must
// reference it by a root-relative path. An absolute URL has to be built from
// cfg.BaseURL, and that value is routinely absent, stale, or aimed at a canonical
// address the browser is not using: it defaults to http://localhost:8080, which a
// browser asked to load it from any other origin refuses, and this application's
// own Content-Security-Policy allows images from 'self' only. A relative path is
// correct by construction in every deployment.
func (s *Server) avatarPath(key *string) string {
if key == nil || *key == "" {
return ""
}
return s.cfg.BaseURL + "/api/v1/avatars/" + *key
return "/api/v1/avatars/" + *key
}
// avatarURL is the absolute form, for the clients that read a picture from another
// origin: /connect/userinfo is consumed by applications such as go-tell, which
// cannot resolve a relative path against their own host.
func (s *Server) avatarURL(r *http.Request, key *string) string {
path := s.avatarPath(key)
if path == "" {
return ""
}
return s.absoluteURL(r, path)
}

View file

@ -9,7 +9,6 @@ import (
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
@ -32,23 +31,44 @@ func profileRequest(t *testing.T, body string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
}
func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
// The console reads its own picture through avatarPath, so it must be a relative
// path: the configured base URL is absent or stale often enough that building an
// absolute link from it yields an address the browser refuses to load.
func TestAvatarPathIsRelativeAndOmittedWithoutKey(t *testing.T) {
server := profileTestServer()
if got := server.avatarURL(nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
if got := server.avatarPath(nil); got != "" {
t.Fatalf("avatarPath(nil) = %q, want empty", got)
}
empty := ""
if got := server.avatarURL(&empty); got != "" {
t.Fatalf("avatarURL(empty) = %q, want empty", got)
if got := server.avatarPath(&empty); got != "" {
t.Fatalf("avatarPath(empty) = %q, want empty", got)
}
key := "k"
got := server.avatarURL(&key)
if want := "https://auth.example.test/api/v1/avatars/k"; got != want {
if got, want := server.avatarPath(&key), "/api/v1/avatars/k"; got != want {
t.Fatalf("avatarPath() = %q, want %q", got, want)
}
// A base URL left at its default must not leak into a page's markup.
server.cfg.BaseURL = "http://localhost:8080"
if got := server.avatarPath(&key); got != "/api/v1/avatars/k" {
t.Fatalf("avatarPath() with default base URL = %q", got)
}
}
// Another origin, such as go-tell reading /connect/userinfo, cannot resolve a
// relative path, so that form follows the request rather than the configuration.
func TestAvatarURLFollowsTheRequestNotTheConfiguredBase(t *testing.T) {
server := profileTestServer()
key := "k"
request := httptest.NewRequest(http.MethodGet, "https://nmbs.auth.example.test/connect/userinfo", nil)
if got, want := server.avatarURL(request, &key), "https://nmbs.auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() = %q, want %q", got, want)
}
parsed, err := url.Parse(got)
if err != nil || parsed.Path != "/api/v1/avatars/k" {
t.Fatalf("avatar url = %q, err = %v", got, err)
if got := server.avatarURL(request, nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
}
// Without a host to follow, the configured base is the only thing left.
if got, want := server.avatarURL(&http.Request{}, &key), "https://auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() without a request host = %q, want %q", got, want)
}
}
@ -58,7 +78,8 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
payload := server.clientUser(store.ClientUser{
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/connect/userinfo", nil)
payload := server.clientUser(request, store.ClientUser{
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
TenantSlug: "nmbs", Application: "guess", Role: "operator",
Permissions: []string{string(role.ManageKeys), string(role.EditProfile)},
@ -84,7 +105,7 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
// rather than an error.
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
payload := server.clientUser(nil, store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
if payload.AvatarURL != "" {
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
}

View file

@ -103,8 +103,10 @@ func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
{"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`},
}
// Permission names are not checked here: the catalog is a table, so an unknown
// name can only be caught by the store, which reports it as a bad request.
// See TestARoleCannotGrantAPermissionThatIsNotInTheCatalog.
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := profileTestServer()

View file

@ -1,6 +1,7 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@ -22,7 +23,6 @@ import (
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
@ -37,6 +37,10 @@ type Server struct {
sessions *session.Manager
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
// endSession is the identity provider's logout endpoint, empty when the
// provider does not advertise one. It is what lets a sign-out here also end
// the provider session the other applications in the family share.
endSession string
authMu sync.RWMutex
installed atomic.Bool
httpClient *http.Client
@ -70,7 +74,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger
if decryptErr != nil {
return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr)
}
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret); err != nil {
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret, installation.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
@ -80,7 +84,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger
} else if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("load installation: %w", err)
} else if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret); err != nil {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
@ -139,6 +143,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/roles", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createRole))))
mux.Handle("PUT /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateRole))))
mux.Handle("DELETE /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deleteRole))))
mux.Handle("GET /api/v1/permissions", s.requireSession(http.HandlerFunc(s.listPermissions)))
mux.Handle("POST /api/v1/permissions", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createPermission))))
mux.Handle("DELETE /api/v1/permissions/{name}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deletePermission))))
return s.recover(s.logRequests(s.securityHeaders(mux)))
}
@ -197,7 +204,8 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" {
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" &&
name != "account.js" {
http.NotFound(w, r)
return
}
@ -210,10 +218,62 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
http.Error(w, "asset unavailable", http.StatusInternalServerError)
return
}
body := content
// HTML pages get the theme origin substituted in. It is a placeholder rather than
// a literal because the origin is deployment configuration, not build-time.
//
// With no origin configured the two theme lines are removed outright rather than
// emitted with an empty href. An empty href is a same-origin request to this
// application, which 404s: a broken stylesheet request in the console, and a
// styling bug report that points at the wrong service entirely.
if path.Ext(name) == ".html" {
body = applyThemeOrigin(content, s.cfg.ThemeBaseURL)
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name)))
w.Write(content)
w.Write(body)
}
// themeOriginPlaceholder is substituted into every HTML page. It is deliberately not
// valid markup, so an unsubstituted page is obviously wrong rather than subtly so.
const themeOriginPlaceholder = "__GO_LOOSE_THEME_ORIGIN__"
// applyThemeOrigin substitutes the theme origin into an HTML page, or removes the
// theme lines entirely when none is configured.
func applyThemeOrigin(content []byte, base string) []byte {
// TrimSpace as well as the trailing slash: an environment variable set to spaces
// is a plausible accident, and it would otherwise produce a relative URL that
// resolves against this application and 404s.
origin := strings.TrimRight(strings.TrimSpace(base), "/")
if origin == "" {
return dropThemeLines(content)
}
return bytes.ReplaceAll(content, []byte(themeOriginPlaceholder), []byte(origin))
}
// dropThemeLines removes every line carrying a theme marker attribute. Markers are
// used rather than the placeholder itself so that the <link> and the <script> can both
// be removed: substituting an empty origin into the script URL would leave a module
// import of this application's own /rt/v1/components.js, which does not exist.
func dropThemeLines(content []byte) []byte {
lines := bytes.Split(content, []byte("\n"))
kept := make([][]byte, 0, len(lines))
for _, line := range lines {
if bytes.Contains(line, []byte(themeMarkerContract)) || bytes.Contains(line, []byte(themeMarkerScript)) {
continue
}
kept = append(kept, line)
}
return bytes.Join(kept, []byte("\n"))
}
// Marker attributes on the theme lines, used only to find and remove them.
const (
themeMarkerContract = "data-theme-contract"
themeMarkerScript = "data-bananas-components"
)
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("return") != "" || s.requestTenantSlug(r.Host) != "" {
http.NotFound(w, r)
@ -342,12 +402,90 @@ func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request, subject, em
http.Redirect(w, r, target, http.StatusSeeOther)
}
// authLogout ends the session and, where the identity provider supports it, the
// provider session the rest of the family shares.
//
// Three things happen, in this order, and the order matters:
//
// 1. Every hosted-login session the user holds is revoked. Go Loose issues those
// bearer tokens, so this is what actually signs the user out of the tenant
// applications: their tokens stop resolving the moment this runs, without the
// other application being told or having to ask. It happens before the local
// cookie is cleared, because the user id is only available while the session
// still exists.
// 2. The local management cookies are cleared.
// 3. The browser is sent to the identity provider's logout endpoint, so the single
// sign-on session that every application in the family relies on ends too. If
// the provider does not advertise an endpoint, this step is skipped and the user
// lands back on the sign-in page: the local sign-out still happened, and the
// other applications end at their next token check rather than immediately.
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
// Read before clearing. A missing or expired session is not a failure to sign
// out, so the claims are best effort and the sign-out proceeds regardless.
claims, err := s.sessions.Get(r)
if err == nil {
if revoked, revokeErr := s.store.RevokeUserClientSessions(r.Context(), claims.UserID); revokeErr != nil {
s.logger.Error("revoke client sessions on logout", "error", revokeErr)
} else if revoked > 0 {
s.logger.Info("revoked client sessions on logout", "count", revoked, "user", claims.UserID)
}
}
s.sessions.Clear(w)
s.sessions.ClearShared(w, s.cfg.AuthDomain)
if target := s.providerLogoutURL(r); target != "" {
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// providerLogoutURL builds the identity provider logout URL, or an empty string when
// this installation has no provider endpoint to send the browser to.
//
// The client id is sent instead of an id_token_hint deliberately. A hint would be
// the precise way to end one particular provider session, but it means keeping a
// live credential in the browser cookie between login and logout, and the cookie has
// a size budget this codebase should not spend on it. Providers accept the client id
// and the post-logout redirect on their own.
func (s *Server) providerLogoutURL(r *http.Request) string {
endpoint, clientID := s.logoutConfiguration()
if endpoint == "" || clientID == "" {
return ""
}
target, err := url.Parse(endpoint)
if err != nil {
return ""
}
query := target.Query()
query.Set("client_id", clientID)
// Bounce back to the sign-in page so the provider has somewhere valid to send
// the browser. An unregistered redirect target is silently ignored by some
// providers, which would leave the user on the provider's own page.
query.Set("post_logout_redirect_uri", s.absoluteURL(r, "/login"))
target.RawQuery = query.Encode()
return target.String()
}
// absoluteURL resolves a local path against the request, honouring the address the
// client actually used so that a tenant host does not receive a link to the apex.
//
// The request wins over cfg.BaseURL. BaseURL is a configuration value that is
// routinely absent or stale -- it defaults to http://localhost:8080 -- so building
// a link from it hands the browser an address it cannot load, and a page served
// under a Content-Security-Policy of 'self' refuses it outright. The request's own
// host is the one address known to be both reachable and same-origin.
func (s *Server) absoluteURL(r *http.Request, path string) string {
if r != nil && r.Host != "" {
return s.requestScheme(r) + "://" + r.Host + path
}
if parsed, err := url.Parse(s.cfg.BaseURL); err == nil && parsed.Host != "" {
return parsed.Scheme + "://" + parsed.Host + path
}
return path
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
@ -394,18 +532,23 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "check system administrator", err)
return
}
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
profile, err := s.store.Profile(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "load profile", err)
return
}
for index := range users {
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
users[index].AvatarURL = s.avatarPath(users[index].AvatarKey)
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "permissions": role.Catalog(),
"users": users, "pending_users": pendingUsers, "permissions": catalog,
"is_system_administrator": isSystemAdministrator,
})
}
@ -431,6 +574,10 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
}
item, err := s.store.CreateTenant(r.Context(), claimsFrom(r).UserID, request.Slug, request.Name)
if err != nil {
if errors.Is(err, store.ErrTenantSlugTaken) {
writeError(w, http.StatusConflict, "a tenant with this slug already exists")
return
}
s.internalError(w, "create tenant", err)
return
}

View file

@ -0,0 +1,91 @@
package server
import (
"strings"
"testing"
)
// The theme origin is substituted into every HTML page. These tests cover the two
// branches and the failure mode in between, because a wrong result here is invisible
// until a user reports that the theme stopped applying.
const pageWithThemeLines = `<!doctype html>
<html data-theme="tokyo" data-mode="dark">
<head>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
</body>
</html>`
func TestThemeOriginIsSubstituted(t *testing.T) {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), "https://themes.example.com"))
if strings.Contains(got, themeOriginPlaceholder) {
t.Error("the placeholder survived substitution")
}
if !strings.Contains(got, `href="https://themes.example.com/rt/v1/contract.css"`) {
t.Errorf("the contract stylesheet was not rewritten:\n%s", got)
}
if !strings.Contains(got, `src="https://themes.example.com/rt/v1/components.js"`) {
t.Errorf("the component module was not rewritten:\n%s", got)
}
// The application's own stylesheet must be untouched.
if !strings.Contains(got, `href="/assets/style.css"`) {
t.Error("the local stylesheet was rewritten")
}
}
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
// A trailing slash would produce a double slash, and the service answers 404 for
// "//rt/v1/contract.css" because the route is matched on the clean path.
for _, base := range []string{
"https://themes.example.com/",
"https://themes.example.com///",
} {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
if strings.Contains(got, "com//") {
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
}
}
}
func TestThemeLinesAreDroppedWhenNoOriginIsConfigured(t *testing.T) {
for _, base := range []string{"", " ", "/"} {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
if strings.Contains(got, themeOriginPlaceholder) {
t.Errorf("base %q left the placeholder in place:\n%s", base, got)
}
// An empty href would be a same-origin request to this application, which 404s
// and looks like a styling bug in the wrong service.
if strings.Contains(got, `href="/rt/v1/contract.css"`) {
t.Errorf("base %q emitted an empty contract href:\n%s", base, got)
}
if strings.Contains(got, themeMarkerContract) || strings.Contains(got, themeMarkerScript) {
t.Errorf("base %q left a theme line behind:\n%s", base, got)
}
// The rest of the page must survive.
if !strings.Contains(got, `href="/assets/style.css"`) {
t.Errorf("base %q removed the local stylesheet:\n%s", base, got)
}
if !strings.Contains(got, "<body>") || !strings.Contains(got, "</html>") {
t.Errorf("base %q damaged the page structure:\n%s", base, got)
}
}
}
func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) {
// A page with no theme lines must come back byte-identical, so that a deployment
// without the contract serving its login and install pages is a no-op.
plain := []byte("<!doctype html>\n<html>\n<body>no theme here</body>\n</html>\n")
if got := applyThemeOrigin(plain, "https://themes.example.com"); string(got) != string(plain) {
t.Errorf("a page without theme lines was modified:\n%s", got)
}
if got := applyThemeOrigin(plain, ""); string(got) != string(plain) {
t.Errorf("a page without theme lines was modified:\n%s", got)
}
}

View file

@ -89,7 +89,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
s.handleStoreError(w, "invite user", err)
return
}
user.AvatarURL = s.avatarURL(user.AvatarKey)
user.AvatarURL = s.avatarPath(user.AvatarKey)
writeJSON(w, http.StatusCreated, user)
}

View file

@ -108,14 +108,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
return ManagedUser{}, err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return ManagedUser{}, err
}
if !within([]string{string(role.ManageUsers)}, held) {
if !permitted {
return ManagedUser{}, ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return ManagedUser{}, err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
if err != nil {
return ManagedUser{}, err
}
@ -161,14 +165,18 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
return err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return err
}
if !within([]string{string(role.ManageUsers)}, held) {
if !permitted {
return ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
if err != nil {
return err
}
@ -410,3 +418,48 @@ func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error
}
return nil
}
// RevokeUserClientSessions ends every hosted-login session belonging to a user and
// reports how many were live.
//
// Go Loose issues the bearer tokens that tenant applications authenticate with, so
// this is what makes signing out here sign you out there: the tokens stop resolving
// immediately, without the other application having to be told or even to ask. It is
// the part of single logout that is enforced here rather than at the identity
// provider.
//
// A user with no sessions is not an error, so the count is returned rather than
// ErrNotFound: logging out twice, or from a session that never used a client
// application, must still succeed.
func (s *Store) RevokeUserClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
result, err := s.db.ExecContext(ctx, `
UPDATE client_user_sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL`, userID)
if err != nil {
return 0, err
}
count, err := result.RowsAffected()
if err != nil {
return 0, err
}
return int(count), nil
}
// CountActiveClientSessions reports how many hosted-login sessions a user holds.
// The account menu uses it to say whether signing out will reach other applications.
func (s *Store) CountActiveClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM client_user_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()`, userID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}

View file

@ -12,6 +12,7 @@ type Installation struct {
OIDCIssuer string
OIDCClientID string
OIDCSecretCiphertext []byte
OIDCRedirectURL string
SeededDemo bool
}
@ -25,9 +26,9 @@ type DemoUser struct {
func (s *Store) Installation(ctx context.Context) (Installation, error) {
var installation Installation
err := s.db.QueryRowContext(ctx, `
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo
FROM installation_settings WHERE singleton = true`).
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.SeededDemo)
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.OIDCRedirectURL, &installation.SeededDemo)
if errors.Is(err, sql.ErrNoRows) {
return Installation{}, ErrNotFound
}
@ -41,9 +42,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo)
VALUES (true, $1, $2, $3, $4)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.SeededDemo); err != nil {
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo)
VALUES (true, $1, $2, $3, $4, $5)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.OIDCRedirectURL, installation.SeededDemo); err != nil {
return err
}
if len(demoUsers) == 0 {
@ -118,14 +119,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT id, $1, r.id FROM tenants, roles r
SELECT t.id, $1, r.id FROM tenants t, roles r
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil {
SELECT a.id, $1, $1 FROM applications a ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
return true, tx.Commit()

View file

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"net/url"
"os"
"strings"
"testing"
@ -24,6 +25,7 @@ func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
if url == "" {
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
requireScratchDatabase(t, url)
ctx := context.Background()
db, err := database.Open(ctx, url)
if err != nil {
@ -39,6 +41,27 @@ func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
return store.New(db), db, ctx
}
// requireScratchDatabase refuses to run against anything that looks like a real
// database. resetSchema drops every table in the public schema, so pointing
// these tests at a development database destroys it. The database name must
// contain "test" unless the operator opts out explicitly.
func requireScratchDatabase(t *testing.T, rawURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" {
return
}
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse GO_LOOSE_TEST_DATABASE_URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run integration tests against database %q: resetSchema drops every table. "+
"Point GO_LOOSE_TEST_DATABASE_URL at a scratch database whose name contains \"test\" "+
"(run `make integration-db` to create one), or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name)
}
}
// grantApplicationAccess inserts an application grant directly so that tests can
// build states the management API intentionally refuses to create, such as a
// grant for a user with no membership.

View file

@ -6,6 +6,16 @@ import (
"errors"
)
// EnsurePasswordUser creates or updates the local account behind
// GO_LOOSE_LOCAL_ADMIN_PASSWORD and bootstraps it into the installation.
//
// The local administrator is operator-supplied rather than created by a visitor,
// so when the installation has no system administrator yet it claims the role.
// Without that, a database-only deployment would have nobody able to create
// tenants or approve waiting-room users, because both require the role that only
// a first OIDC sign-in otherwise grants. The claim is a no-op once any system
// administrator exists, so an installation already owned by a Google account is
// left alone.
func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error {
user, err := s.UpsertUser(ctx, "local:"+email, email, displayName)
if err != nil {
@ -17,6 +27,9 @@ func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, pass
WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil {
return err
}
if _, err := s.ClaimFirstSystemAdministrator(ctx, user.ID); err != nil {
return err
}
return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug)
}

View file

@ -0,0 +1,224 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"github.com/danyel/go-loose/internal/role"
)
// Permission is one entry in the installation-wide catalog. The nine seeded by
// migration 0007 are the capabilities the console enforces; anything added later
// is grantable and reported to client applications but is not checked here,
// because nothing in the request path tests for it.
type Permission struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
System bool `json:"system"`
// RoleCount is how many roles currently grant it. A permission that no role
// grants can be removed again.
RoleCount int `json:"role_count"`
// Enforced reports whether the application itself checks for this
// permission. The console surfaces this so an operator is not misled into
// believing a custom capability is enforced.
Enforced bool `json:"enforced"`
}
var (
// ErrPermissionInUse is returned when a permission a role still grants is
// deleted.
ErrPermissionInUse = errors.New("permission is still granted by a role")
// ErrPermissionTaken is returned when a permission name already exists.
ErrPermissionTaken = errors.New("permission name is already in use")
// ErrPermissionSystem is returned when a built-in permission would be edited
// or deleted.
ErrPermissionSystem = errors.New("built-in permissions cannot be changed")
)
// permissionName constrains a name to lowercase dotted words such as
// "reports.export". The database enforces the same shape.
var permissionName = regexp.MustCompile(`^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$`)
// ValidPermissionName reports whether a name is well formed.
func ValidPermissionName(name string) bool {
return len(name) >= 3 && len(name) <= 100 && permissionName.MatchString(name)
}
// ListPermissions returns the whole catalog, most sensitive first for the
// built-ins and alphabetically after them.
func (s *Store) ListPermissions(ctx context.Context) ([]Permission, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT p.name, p.label, p.description, p.is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = p.name)
FROM permissions p
ORDER BY array_position(`+permissionOrder+`, p.name) NULLS LAST, p.name`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Permission, 0)
for rows.Next() {
var item Permission
if err := rows.Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount); err != nil {
return nil, err
}
item.Enforced = role.Permission(item.Name).Valid()
result = append(result, item)
}
return result, rows.Err()
}
// CreatePermission adds a name to the catalog so that roles can grant it.
// Requires either a system administrator or tenants.manage, because the catalog
// is shared by every tenant rather than scoped to one.
func (s *Store) CreatePermission(ctx context.Context, actorID, name, label, description string) (Permission, error) {
name = strings.TrimSpace(name)
label = strings.TrimSpace(label)
description = strings.TrimSpace(description)
if !ValidPermissionName(name) {
return Permission{}, fmt.Errorf("%w: use lowercase words separated by dots", ErrUnknownPermission)
}
if label == "" || len(label) > 100 {
return Permission{}, fmt.Errorf("%w: label must be between 1 and 100 characters", ErrUnknownPermission)
}
if len(description) > 500 {
return Permission{}, fmt.Errorf("%w: description must be 500 characters or fewer", ErrUnknownPermission)
}
if role.Permission(name).Valid() {
// The name is one the application enforces; it is already seeded, so this
// can only be a duplicate.
return Permission{}, ErrPermissionTaken
}
permitted, err := s.mayAdministerCatalog(ctx, actorID)
if err != nil {
return Permission{}, err
}
if !permitted {
return Permission{}, ErrNotFound
}
item := Permission{Name: name, Label: label, Description: description, Enforced: false}
err = s.db.QueryRowContext(ctx, `
INSERT INTO permissions(name, label, description)
VALUES ($1, $2, $3)
RETURNING name, label, description, is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)`,
name, label, description).
Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount)
if isUniqueViolation(err) {
return Permission{}, ErrPermissionTaken
}
if err != nil {
return Permission{}, err
}
return item, nil
}
// DeletePermission removes a catalog entry that no role grants. Built-in
// permissions are kept: the console enforces them and dropping one would leave a
// membership holding a capability nothing recognizes.
func (s *Store) DeletePermission(ctx context.Context, actorID, name string) error {
permitted, err := s.mayAdministerCatalog(ctx, actorID)
if err != nil {
return err
}
if !permitted {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var (
label string
system bool
grants int
)
err = tx.QueryRowContext(ctx, `
SELECT label, is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)
FROM permissions WHERE name = $1 FOR UPDATE`, name).
Scan(&label, &system, &grants)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if system {
return ErrPermissionSystem
}
if grants > 0 {
return ErrPermissionInUse
}
if _, err := tx.ExecContext(ctx, `DELETE FROM permissions WHERE name = $1`, name); err != nil {
return err
}
return tx.Commit()
}
// mayAdministerCatalog reports whether the actor may change the shared catalog.
// The catalog is installation-wide, so this is a system administrator or an owner
// of some tenant, which is the permission that already means "administer the
// installation".
func (s *Store) mayAdministerCatalog(ctx context.Context, actorID string) (bool, error) {
return mayAdministerCatalog(ctx, s.db, actorID)
}
func mayAdministerCatalog(ctx context.Context, q querier, actorID string) (bool, error) {
var permitted bool
err := q.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
actorID, string(role.ManageTenants)).Scan(&permitted)
return permitted, err
}
// grantablePermissions returns what an actor may hand to somebody: the
// permissions they hold, plus -- for whoever administers the shared catalog --
// every custom permission.
//
// The second half is what makes the catalog usable. A permission nobody holds
// yet could not otherwise be granted, because the escalation guard refuses to
// hand out a capability the granter does not have. Letting the author of a
// permission pass it on resolves that without weakening the guard for enforced
// permissions, which are never in the custom set.
func grantablePermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
held, err := heldPermissions(ctx, q, actorID, tenantID)
if err != nil {
return nil, err
}
administers, err := mayAdministerCatalog(ctx, q, actorID)
if err != nil || !administers {
return held, err
}
rows, err := q.QueryContext(ctx, `SELECT name FROM permissions WHERE NOT is_system`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
if !slices.Contains(held, name) {
held = append(held, name)
}
}
return held, rows.Err()
}
// permissionOrder lists the enforced capabilities so the catalog keeps the same
// most-sensitive-first order the role editor used before it was a table.
var permissionOrder = `ARRAY[
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
]`

View file

@ -0,0 +1,224 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
func TestCatalogSeedsTheEnforcedPermissions(t *testing.T) {
s, _, ctx := integrationStore(t)
catalog, err := s.ListPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
if len(catalog) != len(role.Permissions()) {
t.Fatalf("catalog has %d entries, want the %d seeded permissions", len(catalog), len(role.Permissions()))
}
for _, item := range catalog {
if !item.System {
t.Errorf("%q was not marked built in", item.Name)
}
if !item.Enforced {
t.Errorf("%q is seeded but not reported as enforced", item.Name)
}
if item.Label == "" {
t.Errorf("%q has no label", item.Name)
}
}
// The order the role editor renders is most sensitive first.
if catalog[0].Name != string(role.ManageTenants) {
t.Errorf("catalog starts with %q, want %q", catalog[0].Name, role.ManageTenants)
}
}
func TestTenantCanAddAPermissionAndGrantIt(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs").ID
created, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", "Lets an app export reports.")
if err != nil {
t.Fatalf("create permission: %v", err)
}
if created.System {
t.Error("a permission added through the API must not be marked built in")
}
if created.Enforced {
t.Error("a custom permission cannot be enforced by the console")
}
// It is immediately grantable, exactly like a built-in one.
if _, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), "reports.export"}); err != nil {
t.Fatalf("grant a custom permission: %v", err)
}
catalog, err := s.ListPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
found := false
for _, item := range catalog {
if item.Name == "reports.export" {
found = true
if item.RoleCount != 1 {
t.Errorf("role_count = %d, want 1", item.RoleCount)
}
}
}
if !found {
t.Fatal("the new permission is missing from the catalog")
}
}
func TestDeletingAPermissionInUseIsRefused(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs").ID
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
reporter, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), "reports.export"})
if err != nil {
t.Fatalf("create role: %v", err)
}
if err := s.DeletePermission(ctx, owner, "reports.export"); !errors.Is(err, store.ErrPermissionInUse) {
t.Fatalf("delete a granted permission = %v, want ErrPermissionInUse", err)
}
// Once no role grants it, the delete goes through.
if _, err := s.UpdateRole(ctx, owner, reporter.ID, "Reporter", "",
[]string{string(role.ReadConsole)}); err != nil {
t.Fatalf("drop the custom permission: %v", err)
}
if err := s.DeletePermission(ctx, owner, "reports.export"); err != nil {
t.Fatalf("delete an unused permission: %v", err)
}
}
func TestBuiltInPermissionsCannotBeDeleted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if err := s.DeletePermission(ctx, owner, string(role.ManageUsers)); !errors.Is(err, store.ErrPermissionSystem) {
t.Fatalf("delete a built-in permission = %v, want ErrPermissionSystem", err)
}
}
func TestDuplicateAndMalformedPermissionsAreRejected(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export again", ""); !errors.Is(err, store.ErrPermissionTaken) {
t.Fatalf("duplicate permission = %v, want ErrPermissionTaken", err)
}
// A name the console already enforces cannot be redefined.
if _, err := s.CreatePermission(ctx, owner, string(role.ManageUsers), "Manage", ""); !errors.Is(err, store.ErrPermissionTaken) {
t.Fatalf("redefining a built-in = %v, want ErrPermissionTaken", err)
}
for _, name := range []string{"Reports.Export", "reports export", ".reports", "reports.", "", "ab"} {
if _, err := s.CreatePermission(ctx, owner, name, "Label", ""); err == nil {
t.Errorf("CreatePermission(%q) was accepted", name)
}
}
}
// TestOnlyOwnersMayChangeTheSharedCatalog guards the fact that the catalog is
// installation-wide: a developer who may maintain their own tenant's members
// must not be able to add a capability every tenant can then grant.
func TestOnlyOwnersMayChangeTheSharedCatalog(t *testing.T) {
s, ctx, _, developer, _ := rolesFixture(t)
if _, err := s.CreatePermission(ctx, developer, "reports.export", "Export reports", ""); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer adding a permission = %v, want ErrNotFound", err)
}
if err := s.DeletePermission(ctx, developer, string(role.ReadConsole)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer deleting a permission = %v, want ErrNotFound", err)
}
}
func TestARoleCannotGrantAPermissionThatIsNotInTheCatalog(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
[]string{string(role.ReadConsole), "not.in.the.catalog"})
if !errors.Is(err, store.ErrUnknownPermission) {
t.Fatalf("granting an unknown permission = %v, want ErrUnknownPermission", err)
}
// Nor can an existing role be edited to hold one.
if _, err := s.UpdateRole(ctx, owner, mustSystemRole(t, s, ctx, "viewer"), "Viewer", "",
[]string{string(role.ReadConsole), "not.in.the.catalog"}); !errors.Is(err, store.ErrUnknownPermission) {
t.Fatalf("editing in an unknown permission = %v, want ErrUnknownPermission", err)
}
}
// TestGrantingACustomPermissionNeedsTheRightAuthor documents the rule that makes
// the catalog usable without weakening the escalation guard. Whoever administers
// the catalog authored the permission, so they may pass it on even though nobody
// holds it yet; anybody else still needs to hold it first.
func TestGrantingACustomPermissionNeedsTheRightAuthor(t *testing.T) {
s, ctx, owner, developer, tenantID := rolesFixture(t)
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
// The owner administers the catalog, so they can define a role granting it.
// The role also carries users.manage so that whoever ends up holding it is
// actually able to assign roles to somebody else.
created, err := s.CreateRole(ctx, owner, tenantID, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"})
if err != nil {
t.Fatalf("owner granting a custom permission: %v", err)
}
// A developer administers members but not the catalog, and does not hold the
// permission, so they may not hand the role out.
member := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
if err := s.SetUserAccess(ctx, developer, tenantID, member, created.ID, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer assigning a role carrying an unheld custom permission = %v, want ErrNotFound", err)
}
// Nor may they mint an equivalent role of their own.
if _, err := s.CreateRole(ctx, developer, tenantID, "rogue", "Rogue", "",
[]string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer minting a custom permission = %v, want ErrNotFound", err)
}
// A member who does hold it, and may manage members, may pass it on.
reporter := addMember(t, s, ctx, owner, tenantID, "reporter@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, reporter, created.ID, nil); err != nil {
t.Fatalf("owner assigning the role: %v", err)
}
if err := s.SetUserAccess(ctx, reporter, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("holder passing the permission on: %v", err)
}
// Holding the permission is not enough on its own: a role without
// users.manage cannot assign anything.
readonly, err := s.CreateRole(ctx, owner, tenantID, "readonly-reporter", "Read only reporter", "",
[]string{string(role.ReadConsole), "reports.export"})
if err != nil {
t.Fatalf("create read-only role: %v", err)
}
if err := s.SetUserAccess(ctx, owner, tenantID, reporter, mustSystemRole(t, s, ctx, "viewer"), nil); err != nil {
t.Fatalf("move the reporter aside: %v", err)
}
if err := s.SetUserAccess(ctx, reporter, tenantID, member, readonly.ID, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("assigning without users.manage = %v, want ErrNotFound", err)
}
}
func mustSystemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string {
t.Helper()
id, err := s.SystemRoleID(ctx, slug)
if err != nil {
t.Fatalf("look up %q: %v", slug, err)
}
return id
}

View file

@ -36,9 +36,9 @@ var (
ErrRoleInUse = errors.New("role is still assigned to members")
// ErrRoleSlugTaken is returned when a tenant already uses the requested slug.
ErrRoleSlugTaken = errors.New("role slug is already in use")
// ErrUnknownPermission is returned when a role is defined with a permission
// outside the vocabulary. It is a caller mistake rather than a server fault,
// so callers report it as a bad request.
// ErrUnknownPermission is returned when a role or catalog entry names a
// permission that is not in the catalog. It is a caller mistake rather than a
// server fault, so callers report it as a bad request.
ErrUnknownPermission = errors.New("unknown permission")
)
@ -143,25 +143,54 @@ func within(granted, held []string) bool {
return true
}
// normalizePermissions validates submitted permission names and returns them
// deduplicated in vocabulary order, so a role cannot be stored with an invented
// capability or an unstable order.
func normalizePermissions(permissions []string) ([]string, error) {
// normalizePermissions checks that every submitted name is in the catalog and
// returns them deduplicated in a stable order. Validation moved from the Go
// vocabulary to the database when permissions became rows, so a tenant-defined
// permission can be granted like any other while an invented one still cannot be
// stored.
func normalizePermissions(ctx context.Context, q querier, permissions []string) ([]string, error) {
requested := make(map[string]bool, len(permissions))
for _, value := range permissions {
parsed, ok := role.Parse(value)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, value)
name := strings.TrimSpace(value)
if name == "" {
continue
}
requested[string(parsed)] = true
var known bool
if err := q.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM permissions WHERE name = $1)`, name).Scan(&known); err != nil {
return nil, err
}
if !known {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, name)
}
requested[name] = true
}
rows, err := q.QueryContext(ctx, `
SELECT name FROM permissions
WHERE name = ANY($1)
ORDER BY array_position(`+permissionOrder+`, name) NULLS LAST, name`, keys(requested))
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0, len(requested))
for _, permission := range role.Permissions() {
if requested[string(permission)] {
result = append(result, string(permission))
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, nil
return result, rows.Err()
}
// keys returns the members of a set as a slice, for use as a SQL array parameter.
func keys(set map[string]bool) []string {
result := make([]string, 0, len(set))
for name := range set {
result = append(result, name)
}
return result
}
// roleGrants reads a role's permissions.
@ -280,21 +309,28 @@ func roleByID(ctx context.Context, q querier, roleID string) (Role, error) {
// CreateRole adds a tenant's own role. The caller must hold roles.manage and may
// not mint a role more powerful than one they already hold.
func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, description string, permissions []string) (Role, error) {
granted, err := normalizePermissions(permissions)
if err != nil {
return Role{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Role{}, err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, actorID, tenantID)
granted, err := normalizePermissions(ctx, tx, permissions)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
permitted, err := hasPermission(ctx, tx, actorID, tenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
if !permitted {
return Role{}, ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, tenantID)
if err != nil {
return Role{}, err
}
if !within(granted, grantable) {
return Role{}, ErrNotFound
}
created, err := insertRole(ctx, tx, tenantID, slug, name, description)
@ -316,15 +352,15 @@ func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, d
// UpdateRole renames a role and replaces its permissions. Built-in roles are
// immutable and a tenant may only edit a role it owns.
func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, description string, permissions []string) (Role, error) {
granted, err := normalizePermissions(permissions)
if err != nil {
return Role{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Role{}, err
}
defer tx.Rollback()
granted, err := normalizePermissions(ctx, tx, permissions)
if err != nil {
return Role{}, err
}
existing, err := lockRole(ctx, tx, roleID)
if err != nil {
@ -333,11 +369,18 @@ func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, descripti
if existing.System || existing.TenantID == nil {
return Role{}, ErrNotFound
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
if !permitted {
return Role{}, ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return Role{}, err
}
if !within(granted, grantable) {
return Role{}, ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
@ -379,11 +422,18 @@ func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error {
if err != nil {
return err
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(permissions, held) {
if !permitted {
return ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return err
}
if !within(permissions, grantable) {
return ErrNotFound
}
if existing.MemberCount > 0 {

View file

@ -15,6 +15,9 @@ import (
var ErrNotFound = errors.New("not found")
// ErrTenantSlugTaken is returned when a tenant already uses the requested slug.
var ErrTenantSlugTaken = errors.New("tenant slug is already in use")
type Store struct {
db *sql.DB
}
@ -129,13 +132,21 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
if err != nil {
return err
}
// Ownership is granted only while the tenant still has none. The condition is
// about owners rather than about members: the installer's demonstration seed
// creates tenants that already hold administrators, and refusing ownership
// there would leave a local administrator bootstrapped afterwards with an
// account that can never reach the console. A tenant that already has an owner
// is still never taken over by a later bootstrap.
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, $2, r.id
FROM roles r
WHERE r.slug = $3 AND r.tenant_id IS NULL
AND (NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2))
AND NOT EXISTS (
SELECT 1 FROM memberships m
JOIN roles held ON held.id = m.role_id
WHERE m.tenant_id = $1 AND held.slug = 'owner')
ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil {
return err
}
@ -198,6 +209,11 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
var tenant Tenant
err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name).
Scan(&tenant.ID, &tenant.Slug, &tenant.Name)
if isUniqueViolation(err) {
// Reporting this as a generic failure would surface a 500 for what is an
// ordinary mistake, and the console has no way to explain it.
return Tenant{}, ErrTenantSlugTaken
}
if err != nil {
return Tenant{}, err
}

132
web/account.js Normal file
View file

@ -0,0 +1,132 @@
// The header account control.
//
// One component, mounted into every page by the placeholder below it, so the
// identity and the two things a person can do about their own account live
// together. It replaced a separate sign-out button, which meant the header showed
// two controls describing one fact.
//
// The menu is rendered here rather than duplicated into each page's markup so that
// the keyboard handling, the outside-click behaviour and the sign-out form exist
// once. The trade is one small request for the profile, which the pages that show a
// tenant context were making anyway.
//
// The sign-out item is a real form posting to /auth/logout rather than a fetch, so
// signing out works without JavaScript and cannot be triggered by a stray
// cross-site request: the endpoint only accepts POST.
//
// Everything is wrapped in an IIFE and exposed only as window.mountAccountMenu.
// These are classic scripts sharing one global scope, so a top-level const here
// would collide with the escapeHTML and initials that each page already declares,
// and the collision is a SyntaxError that stops the whole script before it runs.
(function () {
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[character]));
const initials = name => String(name || '?')
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map(part => part[0].toUpperCase())
.join('') || '?';
async function loadProfile() {
const response = await fetch('/api/v1/profile', {
credentials: 'same-origin',
headers: {accept: 'application/json'}
});
if (response.status === 401) {
// Signed out: the page itself will redirect, so there is nothing to show.
return null
}
if (!response.ok) return null;
return response.json()
}
// Mounts the control into a placeholder element. Exported on window because the
// pages load their own script and there is no module loader here; the alternative
// would be repeating this markup and this wiring in every page.
function mount(host) {
if (!host || host.dataset.mounted === 'true') return;
host.dataset.mounted = 'true';
host.classList.add('account');
host.innerHTML = `
<button type="button" class="account-trigger" aria-haspopup="true" aria-expanded="false" hidden>
<span class="account-avatar-slot"></span>
<span class="account-name"></span>
<span class="account-caret" aria-hidden="true">▾</span>
</button>
<div class="account-menu" role="menu" hidden>
<div class="account-identity">
<strong class="account-display-name"></strong>
<span class="account-email"></span>
</div>
<a class="account-profile" role="menuitem" href="/profile">Edit profile</a>
<form method="post" action="/auth/logout">
<button type="submit" role="menuitem">Log out</button>
</form>
<p class="account-note" hidden>Signing out here also ends your sessions in the applications that use this sign-in.</p>
</div>`;
const trigger = host.querySelector('.account-trigger');
const menu = host.querySelector('.account-menu');
const note = host.querySelector('.account-note');
const setOpen = open => {
menu.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
};
trigger.addEventListener('click', event => {
event.stopPropagation();
setOpen(menu.hidden)
});
// Escape closes and returns focus, which is the one keyboard path a menu has to
// get right: without it the menu is a trap for anyone not using a mouse.
document.addEventListener('keydown', event => {
if (event.key !== 'Escape' || menu.hidden) return;
setOpen(false);
trigger.focus()
});
// A click anywhere else dismisses it. Capture phase, so a click that also
// dismisses a dialog underneath does not race this.
document.addEventListener('click', () => setOpen(false), true);
loadProfile().then(profile => {
if (!profile) {
// No session: leave the control hidden rather than showing an empty name.
return
}
const name = profile.display_name || profile.email || '';
// The trigger and the menu both carry the name. Filling only the menu
// leaves the header showing a picture and a caret with no name beside
// them, which is the one thing the control exists to say.
host.querySelector('.account-name').textContent = name;
host.querySelector('.account-display-name').textContent = name;
host.querySelector('.account-email').textContent = profile.email || '';
const slot = host.querySelector('.account-avatar-slot');
slot.innerHTML = profile.avatar_url
? `<img class="avatar" src="${escapeHTML(profile.avatar_url)}" alt="" width="24" height="24">`
: `<span class="avatar avatar-empty">${escapeHTML(initials(name))}</span>`;
trigger.hidden = false;
// Only explain the cross-application effect when it is true. A person with
// no hosted-login sessions has nothing to sign out of elsewhere.
const usesHostedLogin = (profile.memberships || []).length > 0;
note.hidden = !usesHostedLogin
})
};
window.mountAccountMenu = mount
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-account-menu]').forEach(mount)
})
})()

View file

@ -46,7 +46,6 @@ async function load() {
Object.assign(state, data);
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
await loadRoles();
renderIdentity();
render();
}
@ -67,18 +66,6 @@ async function loadRoles() {
}
}
function renderIdentity() {
const profile = state.profile || {};
$('#identity-name').textContent = profile.display_name || profile.email || '';
const image = $('#identity-avatar');
if (profile.avatar_url) {
image.src = profile.avatar_url;
image.hidden = false
} else {
image.hidden = true;
image.removeAttribute('src')
}
}
// assignableRoles is what the invite and access dialogs may offer: the server
// refuses to hand out a role more powerful than the caller's own, so offering
@ -111,12 +98,12 @@ function render() {
$('#user-count').textContent = tenantUsers.length + state.pending_users.length;
$('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
$('#new-tenant').hidden = !state.is_system_administrator;
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet');
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageApplications ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageKeys && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><div class="meta">${metaRow('Slug', item.slug)}${metaRow('Identifier', item.id.slice(0, 8), item.id)}</div></article>`).join('') || empty('No tenants yet');
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta">${metaRow('Client login', item.client_ready ? 'Ready' : 'Not configured')}${item.client_id ? metaRow('Client ID', item.client_id, item.client_id) : ''}</div>${canManageApplications ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td><div class="table-actions">${canManageKeys && item.status === 'active' ? `<button class="table-action danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</div></td></tr>`).join('') || rowEmpty(6, 'No keys issued');
$('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported');
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : '';
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td>${avatarCell(item)}<strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(roleLabel(item.role_id) || item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageUsers ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users');
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><div class="table-actions"><button class="table-action" data-pending="${escapeHTML(item.id)}">Approve</button></div></td></tr>`).join('') : '';
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td>${avatarCell(item)}<strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(roleLabel(item.role_id) || item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td><div class="table-actions">${canManageUsers ? `<button class="table-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</div></td></tr>`).join('') + pending || rowEmpty(6, 'No users');
renderRoleOptions();
$$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
@ -144,6 +131,12 @@ function renderRoleOptions() {
}
const empty = text => `<article class="card"><p>${text}</p></article>`;
// One labelled pair in a card's footer. The value is the half that can be long --
// a client identifier runs to forty-odd characters -- so it is the half allowed to
// shrink and be truncated, with the whole value kept in the title for anyone who
// needs to copy it.
const metaRow = (label, value, title = value) =>
`<div><span>${escapeHTML(label)}</span><b title="${escapeHTML(title)}">${escapeHTML(value)}</b></div>`;
const initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?';
const avatarCell = item => item.avatar_url
? `<img class="avatar" src="${escapeHTML(item.avatar_url)}" alt="" width="28" height="28" loading="lazy">`
@ -292,19 +285,16 @@ function showSecret(title, description, secret) {
$('#secret-dialog').showModal()
}
const theme = $('#theme'), mode = $('#mode');
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
document.documentElement.dataset.theme = theme.value;
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
theme.addEventListener('change', () => {
document.documentElement.dataset.theme = theme.value;
localStorage.setItem('gl-theme', theme.value)
});
mode.addEventListener('click', () => {
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.mode = next;
localStorage.setItem('gl-mode', next)
});
// The palette and appearance controls are the components shipped by the theme
// service, which own data-theme and data-mode on <html>. This file used to drive a
// <select> and a button itself; that duplicated the same two attributes in a second
// place, and whichever loaded last won.
//
// The storage key changes: the components persist under go-bananas:appearance rather
// than gl-theme / gl-mode. A user's previous choice is therefore not carried over on
// first load, and the page starts from the attributes already on <html> — the theme
// these pages ship with. Migrating the old keys would be a nicety, not a
// correctness fix, so it is not done here.
$('#tenant-context').addEventListener('change', async event => {
state.selectedTenant = event.target.value;
await loadRoles();

View file

@ -6,6 +6,12 @@
<meta name="color-scheme" content="dark light">
<title>Go Loose · API access control</title>
<link rel="stylesheet" href="/assets/style.css">
<!-- The shared styling contract. Every palette, both appearances, in one
stylesheet, so switching is a change to an attribute rather than a request.
The origin is substituted by the server from GO_LOOSE_THEME_BASE_URL; when it
is unset the link is simply absent and this application's own theme blocks
take over unchanged. -->
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<div class="noise"></div>
@ -15,12 +21,13 @@
<a href="/docs">API docs</a>
<a href="/roles">Roles</a>
<label class="compact">Tenant<select id="tenant-context"></select></label>
<label class="compact">Theme
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
</label>
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
<a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
<!-- The controls, injected rather than hand-written. They live in a shadow root
so this page's own CSS cannot reach them, and they read and write the same
data-theme and data-mode attributes the stylesheet blocks above key on, so
the values are already the ones this application was storing. -->
<bananas-theme-selector id="theme"></bananas-theme-selector>
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
<div data-account-menu></div>
</nav>
</header>
@ -92,6 +99,12 @@
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role_id" id="access-role" required></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
<dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog>
<script src="/assets/account.js" defer></script>
<script src="/assets/app.js" defer></script>
<!-- Loaded from the theme service rather than vendored, so a change to the controls
needs no rebuild here. A module import is a CORS request, so this deployment's
origin must be in the service's CORS_ALLOWED_ORIGINS; a same-origin
deployment needs nothing configured. -->
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
</body>
</html>

View file

@ -8,16 +8,17 @@
<div class="terminal-body">
<p class="eyebrow">FIRST-RUN INSTALLER</p>
<h1>Initialize<br><span>Go Loose.</span></h1>
<p class="muted">Configure Google identity and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.</p>
<p class="muted">Configure OIDC identity provider and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.</p>
<form action="/install" method="post" class="installer-form">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Google OAuth client ID<input name="oidc_client_id" value="{{GOOGLE_CLIENT_ID}}" required></label>
<label>Google OAuth client secret<input type="password" name="oidc_client_secret" autocomplete="new-password" required></label>
<label>Authorized callback URL<input value="{{CALLBACK_URL}}" readonly></label>
<label>OIDC Issuer<input name="field1" value="https://accounts.google.com" required placeholder="https://accounts.google.com"></label>
<label>Client ID<input name="field2" autocomplete="new-password" required></label>
<label>Client Secret<input type="password" name="field3" autocomplete="new-password" required></label>
<label>Redirect URL<input name="field4" value="{{CALLBACK_URL}}" required></label>
<label class="seed-option"><input type="checkbox" name="seed_demo" checked><span>Seed NMBS and YPTO Guess demo data</span></label>
<button type="submit">INSTALL GO LOOSE</button>
</form>
<p class="muted">In Google Cloud Console, add the callback URL above under Authorized redirect URIs. After installation you will continue to Google, then return to Go Loose with a secure signed session.</p>
<p class="muted">In your identity provider console, add the redirect URL above under Authorized redirect URIs. After installation you will continue to the provider, then return to Go Loose with a secure signed session.</p>
</div>
</main>
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>

View file

@ -94,16 +94,30 @@
},
"PermissionValue": {
"type": "string",
"description": "The closed vocabulary of capabilities a role may grant. Mirrors internal/role and the role_permissions check constraint.",
"enum": ["tenants.manage", "roles.manage", "users.manage", "applications.manage", "keys.manage", "contracts.manage", "users.read", "console.read", "profile.edit"]
"description": "The name of a catalog entry, such as users.manage or reports.export. Names are rows in the permission catalog, so an installation may add its own; the nine seeded ones are the capabilities the console enforces.",
"pattern": "^[a-z0-9][a-z0-9]*(\\.[a-z0-9]+)*$",
"maxLength": 100
},
"PermissionEntry": {
"Permission": {
"type": "object",
"description": "A permission with the label and description the console renders.",
"description": "An entry in the installation-wide permission catalog.",
"required": ["name", "label", "enforced"],
"properties": {
"value": {"$ref": "#/components/schemas/PermissionValue"},
"label": {"type": "string"},
"description": {"type": "string"}
"name": {"$ref": "#/components/schemas/PermissionValue"},
"label": {"type": "string", "maxLength": 100},
"description": {"type": "string", "maxLength": 500},
"system": {"type": "boolean", "description": "True for the seeded capabilities, which cannot be deleted or renamed."},
"enforced": {"type": "boolean", "description": "True when the console itself checks for this permission, so granting it changes what the console allows. False for a permission added through the API: it is reported to client applications but nothing here tests for it."},
"role_count": {"type": "integer", "description": "Roles currently granting it. A permission that a role still grants cannot be deleted."}
}
},
"PermissionRequest": {
"type": "object",
"required": ["name", "label"],
"properties": {
"name": {"$ref": "#/components/schemas/PermissionValue"},
"label": {"type": "string", "minLength": 1, "maxLength": 100},
"description": {"type": "string", "maxLength": 500}
}
},
"RoleRequest": {
@ -114,7 +128,8 @@
"slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$", "description": "Required when creating, and fixed for the life of the role."},
"name": {"type": "string", "minLength": 1, "maxLength": 100},
"description": {"type": "string", "maxLength": 500},
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}}
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"},
"description": "Names from the permission catalog. An unknown name is rejected with 400."}
}
}
}
@ -364,7 +379,7 @@
"post": {
"tags": ["Role Management"],
"summary": "Define a role for a tenant",
"description": "Requires roles.manage in the tenant. The role may not grant a permission the caller does not already hold, so an administrator cannot mint their way above their own authority.",
"description": "Requires roles.manage in the tenant. The role may not grant an enforced permission the caller does not already hold, so an administrator cannot mint their way above their own authority. Whoever administers the catalog may also grant the custom permissions they added, since nobody holds those yet.",
"security": [{"session": []}],
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleRequest"}}}},
"responses": {"201": {"description": "Role created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "400": {"description": "Malformed slug or name, or an unknown permission"}, "404": {"description": "The caller does not hold roles.manage, or the role would exceed their authority"}, "409": {"description": "The slug is already used in this tenant"}}
@ -388,6 +403,32 @@
"responses": {"204": {"description": "Role deleted"}, "404": {"description": "Unknown role, a built-in role, or a role belonging to another tenant"}, "409": {"description": "The role still has members"}}
}
},
"/api/v1/permissions": {
"get": {
"tags": ["Role Management"],
"summary": "List the permission catalog",
"description": "Every management session may read it, because the role editor renders its checkboxes from this list.",
"security": [{"session": []}],
"responses": {"200": {"description": "Catalog", "content": {"application/json": {"schema": {"type": "object", "properties": {"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/Permission"}}}}}}}}
},
"post": {
"tags": ["Role Management"],
"summary": "Add a permission to the catalog",
"description": "Requires a system administrator or tenants.manage, because the catalog is shared by every tenant. The new name becomes grantable in any role and is reported through /connect/userinfo, but nothing in the console checks for it, so it cannot unlock console behaviour.",
"security": [{"session": []}],
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PermissionRequest"}}}},
"responses": {"201": {"description": "Permission added", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Permission"}}}}, "400": {"description": "Malformed name or label"}, "404": {"description": "The caller may not administer the catalog"}, "409": {"description": "The name is already in the catalog, including a seeded one"}}
}
},
"/api/v1/permissions/{name}": {
"delete": {
"tags": ["Role Management"],
"summary": "Delete a permission that no role grants",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "name", "required": true, "schema": {"$ref": "#/components/schemas/PermissionValue"}}],
"responses": {"204": {"description": "Permission deleted"}, "400": {"description": "Malformed name"}, "404": {"description": "Unknown permission or the caller may not administer the catalog"}, "409": {"description": "A role still grants it, or it is one of the seeded capabilities"}}
}
},
"/roles": {
"get": {
"tags": ["Role Management"],

View file

@ -6,6 +6,7 @@
<meta name="color-scheme" content="dark light">
<title>Go Loose · Profile</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
</head>
<body class="profile-page">
<div class="noise"></div>
@ -15,11 +16,9 @@
<a href="/">Console</a>
<a href="/docs">API docs</a>
<a href="/roles">Roles</a>
<label class="compact">Theme
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
</label>
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
<div data-account-menu></div>
</nav>
</header>
@ -75,6 +74,8 @@
</section>
</main>
<script src="/assets/account.js" defer></script>
<script src="/assets/profile.js" defer></script>
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
</body>
</html>

View file

@ -149,18 +149,7 @@ function readAsDataURL(file) {
})
}
const theme = $('#theme');
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
document.documentElement.dataset.theme = theme.value;
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
theme.addEventListener('change', () => {
document.documentElement.dataset.theme = theme.value;
localStorage.setItem('gl-theme', theme.value)
});
$('#mode').addEventListener('click', () => {
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.mode = next;
localStorage.setItem('gl-mode', next)
});
// The palette and appearance controls are the components shipped by the theme
// service. See the note in app.js for why this file no longer drives them.
load().catch(error => notice(error.message, true));

View file

@ -6,6 +6,7 @@
<meta name="color-scheme" content="dark light">
<title>Go Loose · Roles</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<div class="noise"></div>
@ -15,12 +16,9 @@
<a href="/">Console</a>
<a href="/docs">API docs</a>
<label class="compact">Tenant<select id="tenant-context"></select></label>
<label class="compact">Theme
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
</label>
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
<a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
<div data-account-menu></div>
</nav>
</header>
@ -47,6 +45,20 @@
<article><span>Assignable to you</span><strong id="stat-assignable">—</strong></article>
</section>
<section class="panel active" id="permissions">
<div class="panel-head">
<div><p class="eyebrow">CATALOG</p><h2>Permissions</h2></div>
<button id="new-permission">Add permission</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Permission</th><th>Name</th><th>What it allows</th><th>Enforced</th><th>Roles</th><th></th></tr></thead>
<tbody id="permission-list"></tbody>
</table>
</div>
<p class="muted">Every role can grant any name on this list. The nine marked <strong>enforced</strong> are the ones this application checks before it acts, so granting one changes what the console lets you do. A permission you add here is reported to client applications through <code>/connect/userinfo</code>, which can then enforce it themselves, but no part of this console tests for it.</p>
</section>
<section class="panel active" id="roles">
<div class="panel-head">
<div><p class="eyebrow">PERMISSION CATALOG</p><h2>Roles</h2></div>
@ -62,12 +74,9 @@
</section>
<section class="panel active" id="permission-reference">
<div class="panel-head"><div><p class="eyebrow">VOCABULARY</p><h2>Permissions</h2></div></div>
<div class="table-wrap">
<table>
<thead><tr><th>Permission</th><th>What it allows</th></tr></thead>
<tbody id="permission-list"></tbody>
</table>
<div class="panel-head"><div><p class="eyebrow">HOW THEY ARE USED</p><h2>Reading a permission</h2></div></div>
<div class="check-list">
<p class="muted">A membership resolves to exactly one role, and that role resolves to a set of permissions. Every tenant-scoped statement asks the database whether the caller holds the permission it needs, so a tenant-defined role is enforced the same way a built-in one is. Assigning a role only succeeds when every permission it grants is one you already hold, which is what stops handing out a role from becoming a way to gain authority.</p>
</div>
</section>
</main>
@ -85,6 +94,19 @@
</form>
</dialog>
<dialog id="permission-dialog">
<form id="permission-form">
<div class="dialog-head"><h3>Add permission</h3><button type="button" class="close">×</button></div>
<label>Name<input name="name" required maxlength="100" pattern="[a-z0-9][a-z0-9]*(\.[a-z0-9]+)*" placeholder="reports.export"><small>lowercase words separated by dots</small></label>
<label>Label<input name="label" required maxlength="100" placeholder="Export reports"></label>
<label>Description <small>(optional)</small><textarea name="description" maxlength="500" placeholder="Allows a client application to export the tenant's reports."></textarea></label>
<p class="muted">This becomes selectable in every role. Nothing in this console checks for it; it is reported to client applications, which can enforce it themselves.</p>
<button type="submit">Add permission</button>
</form>
</dialog>
<script src="/assets/account.js" defer></script>
<script src="/assets/roles.js" defer></script>
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
</body>
</html>

View file

@ -31,28 +31,24 @@ async function api(path, options = {}) {
return body
}
async function loadPermissions() {
try {
state.permissions = (await api('/api/v1/permissions')).permissions
} catch (error) {
notice('You do not have permission to read the permission catalog.', true)
}
}
async function load() {
const dashboard = await api('/api/v1/dashboard');
state.tenants = dashboard.tenants;
state.permissions = dashboard.permissions || [];
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
renderIdentity(dashboard.profile);
await loadRoles();
// Roles and the permission catalog are fetched from their own endpoints so
// that changing either does not need the whole dashboard reloaded.
await Promise.all([loadRoles(), loadPermissions()]);
render()
}
function renderIdentity(profile) {
profile = profile || {};
$('#identity-name').textContent = profile.display_name || profile.email || '';
const image = $('#identity-avatar');
if (profile.avatar_url) {
image.src = profile.avatar_url;
image.hidden = false
} else {
image.hidden = true;
image.removeAttribute('src')
}
}
async function loadRoles() {
if (!state.selectedTenant) {
@ -72,6 +68,9 @@ function selectedTenant() {
}
const canManageRoles = () => Boolean(selectedTenant()?.permissions?.includes('roles.manage'));
// The catalog is shared by every tenant, so changing it needs tenants.manage
// rather than roles.manage.
const canAdministerCatalog = () => Boolean(state.tenants.some(tenant => tenant.permissions?.includes('tenants.manage')));
function render() {
const tenant = selectedTenant();
@ -86,6 +85,7 @@ function render() {
$('#stat-custom').textContent = custom.length;
$('#stat-assignable').textContent = state.roles.filter(item => item.assignable).length;
$('#new-role').hidden = !canManageRoles();
$('#new-permission').hidden = !canAdministerCatalog();
$('#role-list').innerHTML = state.roles.map(item => `<tr>
<td><strong>${escapeHTML(item.name)}</strong>${item.system ? ' <span class="badge">built-in</span>' : ''}<br><small>${escapeHTML(item.description || 'No description')}</small></td>
@ -93,20 +93,24 @@ function render() {
<td class="capabilities">${item.permissions.map(permission => `<span class="chip">${escapeHTML(permission)}</span>`).join('') || '<span class="muted">None</span>'}</td>
<td>${item.member_count}</td>
<td>${item.assignable ? 'Yes' : '<span class="muted">No</span>'}</td>
<td>${item.system ? '' : canManageRoles() ? `<button class="card-action" data-edit="${escapeHTML(item.id)}">Edit</button><button class="danger" data-delete="${escapeHTML(item.id)}">Delete</button>` : ''}</td>
<td><div class="table-actions">${item.system ? '' : canManageRoles() ? `<button class="table-action" data-edit="${escapeHTML(item.id)}">Edit</button><button class="table-action danger" data-delete="${escapeHTML(item.id)}">Delete</button>` : ''}</div></td>
</tr>`).join('') || '<tr><td colspan="6">No roles available for this tenant.</td></tr>';
$('#permission-list').innerHTML = state.permissions.map(item => `<tr>
<td><code>${escapeHTML(item.value)}</code></td>
<td>${escapeHTML(item.description)}</td>
</tr>`).join('')
<td><strong>${escapeHTML(item.label)}</strong>${item.system ? ' <span class="badge">built-in</span>' : ''}</td>
<td><code>${escapeHTML(item.name)}</code></td>
<td>${escapeHTML(item.description || 'No description')}</td>
<td>${item.enforced ? 'Yes' : '<span class="muted">No</span>'}</td>
<td>${item.role_count}</td>
<td><div class="table-actions">${item.system || !canAdministerCatalog() ? '' : `<button class="table-action danger" data-delete-permission="${escapeHTML(item.name)}">Delete</button>`}</div></td>
</tr>`).join('') || '<tr><td colspan="6">No permissions in the catalog.</td></tr>'
}
function permissionCheckboxes(selected) {
const held = new Set(selected || []);
$('#role-permissions').innerHTML = state.permissions.map(item => `<label>
<input type="checkbox" name="permissions" value="${escapeHTML(item.value)}" ${held.has(item.value) ? 'checked' : ''}>
<span><strong>${escapeHTML(item.label)}</strong><br><small>${escapeHTML(item.description)}</small></span>
<input type="checkbox" name="permissions" value="${escapeHTML(item.name)}" ${held.has(item.name) ? 'checked' : ''}>
<span><strong>${escapeHTML(item.label)}</strong>${item.enforced ? '' : ' <small>(not enforced here)</small>'}<br><small>${escapeHTML(item.description || item.name)}</small></span>
</label>`).join('')
}
@ -130,6 +134,29 @@ function openDialog(item) {
}
$('#new-role').addEventListener('click', () => openDialog(null));
$('#new-permission').addEventListener('click', () => $('#permission-dialog').showModal());
$('#permission-form').addEventListener('submit', async event => {
event.preventDefault();
const form = event.target;
try {
await api('/api/v1/permissions', {
method: 'POST',
body: JSON.stringify({
name: form.name.value.trim(),
label: form.label.value.trim(),
description: form.description.value.trim()
})
});
form.closest('dialog').close();
form.reset();
notice('Permission added to the catalog');
await loadPermissions();
render()
} catch (error) {
notice(error.message, true)
}
});
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
$('#role-form').addEventListener('submit', async event => {
@ -160,6 +187,19 @@ document.addEventListener('click', async event => {
openDialog(state.roles.find(item => item.id === edit));
return
}
const removePermission = event.target.dataset.deletePermission;
if (removePermission) {
if (!confirm(`Delete the permission "${removePermission}"?`)) return;
try {
await api(`/api/v1/permissions/${encodeURIComponent(removePermission)}`, {method: 'DELETE'});
notice('Permission deleted');
await loadPermissions();
render()
} catch (error) {
notice(error.message, true)
}
return
}
const remove = event.target.dataset.delete;
if (remove) {
const item = state.roles.find(role => role.id === remove);
@ -175,19 +215,8 @@ document.addEventListener('click', async event => {
}
});
const theme = $('#theme');
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
document.documentElement.dataset.theme = theme.value;
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
theme.addEventListener('change', () => {
document.documentElement.dataset.theme = theme.value;
localStorage.setItem('gl-theme', theme.value)
});
$('#mode').addEventListener('click', () => {
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.mode = next;
localStorage.setItem('gl-mode', next)
});
// The palette and appearance controls are the components shipped by the theme
// service. See the note in app.js for why this file no longer drives them.
$('#tenant-context').addEventListener('change', async event => {
state.selectedTenant = event.target.value;
await loadRoles();

View file

@ -9,7 +9,7 @@
*{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
.docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)}
main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)}
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px}.card h3{margin:0 0 5px}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);min-height:24px}.card-action{margin-top:16px;width:100%;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:flex;justify-content:space-between;margin-top:18px;color:var(--muted);font-size:12px}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px}
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:flex-start}.card h3{margin:6px 0 5px;width:100%}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);margin:0;width:100%;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-action{margin-top:14px;align-self:flex-start;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:grid;gap:7px;width:100%;margin-top:auto;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}.meta>div{display:flex;align-items:baseline;gap:14px;min-width:0}.meta span{flex:none;text-transform:uppercase;letter-spacing:.09em;font-size:10px}.meta b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px ui-monospace,monospace;color:var(--text)}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px}
dialog{color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 30px 100px rgba(0,0,0,.6);width:min(520px,92vw);padding:24px}dialog::backdrop{background:rgba(8,8,15,.74);backdrop-filter:blur(4px)}dialog form{display:grid;gap:16px}.dialog-head{display:flex;align-items:center;justify-content:space-between}.dialog-head h3{margin:0;font-size:22px}.close{border:0;background:transparent;color:var(--muted);font-size:28px;cursor:pointer}dialog label{display:grid;gap:7px;font-size:13px;color:var(--muted)}textarea{resize:vertical}.segmented{display:flex;gap:20px}.segmented label{display:flex;align-items:center;grid-template-columns:auto auto}.secret{display:flex;gap:8px;align-items:center;background:var(--bg);padding:12px;border-radius:8px}.secret code{overflow-wrap:anywhere;color:var(--good);flex:1}
.check-list{display:grid;gap:8px}.check-list label{display:flex;grid-template-columns:auto 1fr;align-items:center}.client-terminal h1{font-size:clamp(38px,6vw,64px);line-height:1;margin:10px 0 32px;letter-spacing:-.05em}.client-terminal h1 span{color:var(--accent2)}
.login-page{display:grid;place-items:center;min-height:100vh;overflow:hidden}.login-glow{position:fixed;width:600px;height:600px;border-radius:50%;background:var(--accent2);filter:blur(180px);opacity:.13}.terminal{width:min(920px,92vw);margin:40px;position:relative;background:color-mix(in srgb,var(--surface) 94%,black);border:1px solid var(--border);border-radius:10px;box-shadow:0 40px 120px rgba(0,0,0,.55);overflow:hidden;font-family:ui-monospace,SFMono-Regular,monospace}.terminal-bar{height:44px;background:var(--surface2);display:flex;align-items:center;gap:8px;padding:0 15px}.terminal-bar span{width:11px;height:11px;border-radius:50%;background:var(--danger)}.terminal-bar span:nth-child(2){background:#e0af68}.terminal-bar span:nth-child(3){background:var(--good)}.terminal-bar strong{position:absolute;left:50%;transform:translateX(-50%);color:var(--muted);font-size:12px}.terminal-body{padding:35px}.ascii{color:var(--accent);font-size:clamp(6px,1vw,12px);line-height:1.2;overflow:hidden}.prompt{color:var(--good)}.muted{color:var(--muted);max-width:650px;line-height:1.6}.terminal-login{display:inline-flex;gap:10px;text-decoration:none;margin:20px 0 10px}.dev-login{display:block;color:var(--muted);font-size:11px}.cursor{color:var(--accent);animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.docs-page #swagger-ui{max-width:1200px;margin:30px auto}.docs-page .swagger-ui{filter:none}
@ -17,3 +17,40 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border
.installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)}
@media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}}
.identity{display:inline-flex;align-items:center;gap:8px;text-decoration:none}.identity img,.avatar{border-radius:50%;object-fit:cover;display:block;flex:none}.profile-hero{min-height:0;padding:56px 0 34px}.profile-hero h1{font-size:clamp(40px,5vw,64px)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin-top:34px}.profile-card{display:grid;gap:16px;align-content:start}.profile-card h2{margin:0;font-size:20px;letter-spacing:-.02em}.profile-wide{grid-column:1/-1}.profile-card form{display:grid;gap:14px}.profile-card label{display:grid;gap:7px;font-size:13px;color:var(--muted)}.profile-actions{display:flex;gap:10px;flex-wrap:wrap}.profile-actions button{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions .ghost{background:transparent;border:1px solid var(--border);color:var(--text)}.profile-card button[type=submit]{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer;justify-self:start}.avatar-preview{display:grid;place-items:center;width:112px;height:112px;border-radius:50%;overflow:hidden;background:var(--surface2);border:1px solid var(--border)}.avatar-preview img{width:100%;height:100%;object-fit:cover}.avatar-initials{font:750 34px ui-monospace,monospace;color:var(--accent)}.avatar-empty{display:inline-grid;place-items:center;width:28px;height:28px;vertical-align:middle;background:var(--surface2);border:1px solid var(--border);color:var(--accent);font:700 11px ui-monospace,monospace}.facts{display:grid;gap:10px;margin:4px 0 0}.facts div{display:flex;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);padding-bottom:8px;font-size:13px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right}.capabilities{display:flex;flex-wrap:wrap;gap:6px}.chip{font:600 11px ui-monospace,monospace;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 6px}
/* ---------------------------------------------------------- account menu --
The header account control: a trigger showing who is signed in, opening a
small menu with the two things a person can do about their own account. It
replaces the separate sign-out button, so the identity and the action live in
one place instead of two controls that mean the same thing. */
.account{position:relative;margin-left:2px}
.account-trigger{display:inline-flex;align-items:center;gap:8px;padding:5px 9px 5px 6px;background:transparent;border:1px solid var(--border);border-radius:8px;color:var(--text);cursor:pointer;font:inherit;font-size:13px}
.account-trigger:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}
.account-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.account-trigger .avatar{width:24px;height:24px}
.account-name{font-weight:650;max-width:11rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.account-caret{color:var(--muted);font-size:10px}
.account-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:40;min-width:15rem;padding:6px;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 14px 34px rgba(0,0,0,.28)}
.account-menu[hidden]{display:none}
.account-identity{padding:9px 10px 11px;border-bottom:1px solid var(--border);margin-bottom:6px}
.account-identity strong{display:block;font-size:13px}
.account-identity span{display:block;color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.account-menu form{margin:0}
.account-menu a,.account-menu button{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;background:transparent;border:0;border-radius:7px;color:var(--text);font:inherit;font-size:13px;text-align:left;text-decoration:none;cursor:pointer}
.account-menu a:hover,.account-menu button:hover{background:var(--surface2)}
.account-menu a:focus-visible,.account-menu button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.account-note{padding:7px 10px 4px;color:var(--muted);font-size:11px;line-height:1.45}
/* Row actions inside a table. The existing .card-action is a full-width block
button meant for the foot of a card, which stacked two of them in a narrow
cell and made the row taller than its content. These are inline and sit on the
text baseline. */
.table-actions{display:flex;gap:6px;justify-content:flex-end;white-space:nowrap}
.table-action{padding:5px 10px;background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--text);font:inherit;font-size:12px;cursor:pointer}
.table-action:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));color:var(--accent)}
.table-action:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.table-action.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,var(--border))}
.table-action.danger:hover{background:color-mix(in srgb,var(--danger) 12%,transparent)}
/* A slug, prefix or permission name is an identifier: wrapping it across two lines
makes one row taller than its neighbours for no benefit. */
.table-wrap td code{white-space:nowrap}