diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..a7ef3a2
--- /dev/null
+++ b/.air.toml
@@ -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
diff --git a/.dockerignore b/.dockerignore
index cc30164..a453382 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,4 +4,6 @@ bin
coverage.out
google_secrets.json
data
-
+_data-old-bind-mount
+dist
+tmp
diff --git a/.gitignore b/.gitignore
index afa1b75..5a960dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,6 @@ bin/
coverage.out
google_secrets.json
-data/
\ No newline at end of file
+data/
+_data-old-bind-mount/
+dist/
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 0000000..169b9b5
--- /dev/null
+++ b/Dockerfile.dev
@@ -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"]
diff --git a/Makefile b/Makefile
index 7b36763..1abb030 100644
--- a/Makefile
+++ b/Makefile
@@ -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 ./...
diff --git a/README.md b/README.md
index c38b365..cfe85ad 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml
new file mode 100644
index 0000000..3d6f157
--- /dev/null
+++ b/docker-compose.dev.yaml
@@ -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:
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 9a1ad53..eabdc54 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -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:
diff --git a/docs/client-login.md b/docs/client-login.md
index e31b466..55d6826 100644
--- a/docs/client-login.md
+++ b/docs/client-login.md
@@ -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
diff --git a/docs/coding-standards.md b/docs/coding-standards.md
index 87a9a03..b25dc7f 100644
--- a/docs/coding-standards.md
+++ b/docs/coding-standards.md
@@ -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 ./...`.
diff --git a/docs/roles.md b/docs/roles.md
index 3016b7a..7b85820 100644
--- a/docs/roles.md
+++ b/docs/roles.md
@@ -1,88 +1,91 @@
-# Roles and capabilities
+# Roles, permissions, and profiles
-A role is a named bundle of permissions. Roles are rows: six built-in ones seeded by migration `0006_database_roles.sql`, plus whatever each tenant defines for itself. `internal/role` no longer holds a matrix — it holds the closed vocabulary of permissions a role may be granted, and the database resolves what a membership actually grants.
+Authorization is database-driven. Permissions and roles are rows, not Go constants, so a tenant can define its own vocabulary without a code change or a migration.
-```
-roles one row per role. tenant_id NULL means built-in.
-role_permissions (role_id, permission) — what the role grants.
-memberships.role_id → roles.id, NOT NULL, ON DELETE RESTRICT
-```
+## Data model
-## Built-in capability matrix
+- `permissions` is the closed vocabulary. Every row carries a `name`, a `label`, a `description`, and an `is_system` flag. System permissions cannot be edited or deleted.
+- `roles` holds the roles. A `NULL tenant_id` marks a built-in role that every tenant sees and nobody may modify. A custom role belongs to exactly one tenant.
+- `role_permissions` joins roles to permissions.
+- `memberships.role_id` points at a role row. The older `memberships.role` text column was dropped in `0006_database_roles.sql`.
-This is what the migration seeds. It is data, not code: change it with a new migration, not by editing Go.
+Nine permissions ship as system rows:
+
+| Permission | Label |
+| --- | --- |
+| `tenants.manage` | Manage tenants |
+| `roles.manage` | Manage roles |
+| `users.manage` | Manage members |
+| `applications.manage` | Manage applications |
+| `keys.manage` | Manage API keys |
+| `contracts.manage` | Manage contracts |
+| `users.read` | Read members |
+| `console.read` | Read console |
+| `profile.edit` | Edit own profile |
+
+A tenant may add further permission names. They are grantable and reported through `/connect/userinfo`, but the console does not enforce them.
+
+## Built-in roles
+
+`0006_database_roles.sql` seeds these six roles. Note that `developer` and `operator` hold `users.manage`: any tenant member who runs the console can maintain their own tenant's members. Minting roles stays with `owner` and `admin` through `roles.manage`, because defining privilege is the operation that actually escalates it.
| Permission | owner | admin | developer | operator | viewer | user |
| --- | --- | --- | --- | --- | --- | --- |
-| `tenants.manage` | yes | no | no | no | no | no |
-| `roles.manage` | yes | yes | no | no | no | no |
-| `users.manage` | yes | yes | yes | yes | no | no |
+| `console.read` | yes | yes | yes | yes | yes | no |
+| `users.read` | yes | yes | yes | yes | yes | no |
| `applications.manage` | yes | yes | yes | no | no | no |
| `keys.manage` | yes | yes | yes | yes | no | no |
| `contracts.manage` | yes | yes | yes | yes | no | no |
-| `users.read` | yes | yes | yes | yes | yes | no |
-| `console.read` | yes | yes | yes | yes | yes | no |
+| `users.manage` | yes | yes | yes | yes | no | no |
+| `roles.manage` | yes | yes | no | no | no | no |
+| `tenants.manage` | yes | no | no | no | no | no |
| `profile.edit` | yes | yes | yes | yes | yes | yes |
-Intended use:
+`user` members have no `console.read`, so a session that lands on `/` is redirected to `/profile`. A `user` with no membership at all still sees the waiting room.
-- **owner** — full control of the tenant, including defining roles and handing them out.
-- **admin** — manages applications, keys, contracts, members, and roles.
-- **developer** — ships applications, keys, and contracts, and maintains the tenant's members.
-- **operator** — runs existing applications and maintains the tenant's members.
-- **viewer** — read-only console access.
-- **user** — self-service only. No console access; the session lands on `/profile`.
+## Managing roles
-`developer` and `operator` hold `users.manage` so that anyone who runs the console can maintain their own tenant's members. `roles.manage` deliberately stays narrow: defining roles is what defines privilege, so it is not something a member administrator can grant themselves.
+`/roles` is the console page. The API is:
-`user` members without any tenant membership still see the waiting room. Those with a membership are redirected from `/` to `/profile`, because the dashboard requires `console.read`.
+| Route | Purpose |
+| --- | --- |
+| `GET /api/v1/roles` | List roles with their permissions and member counts |
+| `POST /api/v1/roles` | Create a custom role for one tenant |
+| `PUT /api/v1/roles/{id}` | Rename a role or change its permissions |
+| `DELETE /api/v1/roles/{id}` | Delete a custom role |
+| `GET /api/v1/permissions` | List the permission catalog |
+| `POST /api/v1/permissions` | Add a permission to the catalog |
+| `DELETE /api/v1/permissions/{name}` | Remove an unused permission |
-## Tenant-defined roles
+Two rules keep this from becoming a privilege-escalation hole:
-`POST /api/v1/roles` creates a role for one tenant. It is visible only in that tenant, and the `/roles` screen lists it beside the built-ins with its permission chips.
+- **Assignability is a subset rule.** A role is offered as `assignable` only when every permission it grants is one the requesting administrator already holds. `ListRoles` computes this per caller, so managing members can never hand out more authority than the administrator has.
+- **Roles in use cannot be deleted.** `memberships.role_id` is `ON DELETE RESTRICT` and `DeleteRole` returns `ErrRoleInUse`, so a role cannot be pulled out from under somebody. Built-in roles are likewise undeletable.
-Three rules keep this from becoming a privilege-escalation hole:
+`role_permissions.permission` is a foreign key onto `permissions.name`, so a role cannot reference a capability that is not in the catalog.
-1. **Roles are scoped.** `roles.tenant_id` is set, and every query that resolves a role filters on the caller's own tenant, so a role id cannot be probed across tenants.
-2. **You cannot mint what you do not hold.** Creating or editing a role requires `roles.manage` *and* requires every permission in the role to be one the caller already has. Assigning a role enforces the same subset rule.
-3. **A role in use cannot be deleted.** `memberships.role_id` is `ON DELETE RESTRICT`, and `DELETE /api/v1/roles/{id}` reports 409 until the members are moved. Built-in roles are immutable and report 404.
+## Go side
-Built-in roles are read only: the store refuses to edit or delete a row with `is_system` set.
+`internal/role` holds only the permission vocabulary used to build the seed data and to keep the Go and SQL spellings in step. It intentionally has no role-to-permission matrix: that mapping lives in the database, so queries resolve a member's real capabilities with a join on `role_permissions` rather than against a hardcoded list.
-## Where the checks live
-
-Tenant-scoped statements resolve the grant through `role_permissions` using one shared predicate, `permissionPredicate` in `internal/store/roles.go`:
-
-```sql
-EXISTS (
- SELECT 1
- FROM memberships scoped
- JOIN role_permissions granted ON granted.role_id = scoped.role_id
- WHERE scoped.tenant_id = $1 AND scoped.user_id = $2
- AND granted.permission = $3
-)
-```
-
-Because the check asks for a permission rather than a role name, a tenant's own role works everywhere a built-in one does without any Go change. The server never decides authorization: `handleStoreError` maps `store.ErrNotFound` to a 404 so a denied write and a missing row are indistinguishable.
-
-`role.Permission` names are also constrained by a check constraint on `role_permissions.permission`, so an unknown capability cannot be stored even if a caller bypasses the Go layer. **Adding a permission therefore needs three edits**: the constant in `internal/role/role.go`, the check constraint and seed in a new migration, and the expected grants in `internal/store/roles_integration_test.go`.
+Store methods enforce capabilities in SQL inside the same transaction as the write, so a request cannot pass a handler check and then fail, or the reverse.
## Display names and profile pictures
-Every management session can open `/profile` and set a display name and a profile picture, independent of tenant role.
+Every management session can open `/profile` and set a display name and a picture, independent of tenant role.
-Setting the display name flags the user as `name_customized`, which stops the identity provider from overwriting it on the next login. The row is still matched by email on every login, so customization never breaks sign-in.
+Setting the display name sets `name_customized`, which stops the identity provider from overwriting it on the next login. The row is still matched by email on every login, so customization never breaks sign-in.
-Pictures are stored as bytes in PostgreSQL with a random 256-bit key and served from the public `GET /api/v1/avatars/{key}` route. The URL is unguessable and carries no user identity, so applications can embed it directly:
+Pictures are stored as bytes in PostgreSQL under a random 256-bit key and served from the public `GET /api/v1/avatars/{key}` route. The URL is unguessable and carries no user identity, so applications can embed it directly:
```html
```
-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`.
\ No newline at end of file
+`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`.
\ No newline at end of file
diff --git a/internal/config/config.go b/internal/config/config.go
index 9b570e4..c4bc04c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -29,24 +29,33 @@ 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) {
baseURL := strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/")
cfg := Config{
- Addr: env("GO_LOOSE_ADDR", ":8080"),
- BaseURL: baseURL,
- AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.dev")), "."),
- DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
- SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
- DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
- DevUser: env("GO_LOOSE_DEV_USER", "admin@local.test"),
- LocalLogin: envBool("GO_LOOSE_LOCAL_LOGIN", false),
- LocalAdminEmail: strings.ToLower(env("GO_LOOSE_LOCAL_ADMIN_EMAIL", "admin@goloose.local")),
- LocalAdminPassword: os.Getenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD"),
- 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"),
+ Addr: env("GO_LOOSE_ADDR", ":8080"),
+ BaseURL: baseURL,
+ AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.dev")), "."),
+ DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
+ SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
+ DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
+ DevUser: env("GO_LOOSE_DEV_USER", "admin@local.test"),
+ LocalLogin: envBool("GO_LOOSE_LOCAL_LOGIN", false),
+ LocalAdminEmail: strings.ToLower(env("GO_LOOSE_LOCAL_ADMIN_EMAIL", "admin@goloose.local")),
+ LocalAdminPassword: os.Getenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD"),
+ 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),
diff --git a/internal/database/migrations/0007_permission_catalog.sql b/internal/database/migrations/0007_permission_catalog.sql
new file mode 100644
index 0000000..ef57cb9
--- /dev/null
+++ b/internal/database/migrations/0007_permission_catalog.sql
@@ -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;
\ No newline at end of file
diff --git a/internal/database/migrations/0008_oidc_redirect_url.sql b/internal/database/migrations/0008_oidc_redirect_url.sql
new file mode 100644
index 0000000..f2c2193
--- /dev/null
+++ b/internal/database/migrations/0008_oidc_redirect_url.sql
@@ -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;
\ No newline at end of file
diff --git a/internal/server/account_test.go b/internal/server/account_test.go
new file mode 100644
index 0000000..4cd338d
--- /dev/null
+++ b/internal/server/account_test.go
@@ -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, `