Compare commits

..

10 commits

Author SHA1 Message Date
Daniel Noulet
0f5d582d1f chore: helm configuration 2026-10-07 15:07:22 +02:00
Daniel Noulet
49ab89ae46 refactor: keep tenant applications out of the administrator's reach
Claiming the system administrator role no longer grants access to every
application in the installation. Owning a tenant is what lets an administrator
administer it; entering the applications running inside it is a separate,
explicit grant that can be withdrawn again. Handing it out at claim time gave
every installation a standing administrator account inside every customer
application, which is the opposite of the boundary this product draws.

The behaviour suite now covers single sign-on between applications directly: a
second application on the same tenant must receive a code and identify the same
person with no sign-in page in between, once Go Loose already holds a session.

Also adds theme-token tests against the shared styling contract and store
coverage for application grants, and drops the redundant server and security
scheme declarations from the published specification.
2026-10-05 21:48:05 +02:00
Daniel Noulet
8d8970d5a7 test: drive the whole application over HTTP, and serialise migrations
The behaviour suite in internal/behaviour installs the application into an
empty database through the web installer, signs in, walks every flow, and then
replaces the process to confirm the state was persisted rather than held in
memory. It found four defects, each fixed here or in the preceding commit:

- Migrate was not safe to run from two instances at once. CREATE TABLE IF NOT
  EXISTS is not race free in PostgreSQL and collided on the pg_type unique
  index, and two replicas could apply the same file. The run now holds a
  session-level advisory lock, so a rolling deployment waits instead of failing.

- Bootstrap refused ownership whenever a tenant held any member, so installing
  with the demonstration seed and then setting a local administrator password
  produced an account that could never reach the console. It now grants
  ownership only while the tenant has no owner, which still never takes a tenant
  over from one that does.

Three of the fixes are covered by store-level regression tests in the same
commit: the ownership rule, the duplicate tenant slug conflict, and concurrent
migration runs.

The suite is skipped by go test ./... because it needs a scratch database and
the Google credentials for the installer. Run it with make behaviour-test, which
also resets the database afterwards. make test no longer inherits the database
variables, so unit runs need nothing.

Completes the earlier correction to the docs: the role matrix lives in the
database rather than in internal/role, and a local administrator claims the
system administrator role when no Google account has.
2026-10-05 19:37:09 +02:00
Daniel Noulet
c7c81fb65c 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.
2026-10-05 19:18:07 +02:00
Daniel Noulet
3fdc8cb489 roles 2026-10-04 16:57:30 +02:00
Daniel Noulet
6319da650c fix installer 2026-10-04 12:24:13 +02:00
Daniel Noulet
318fb51085 fix: changing url 2026-10-04 11:02:23 +02:00
Daniel Noulet
b984c8a90c feat: read the identity domain from the environment
Compose hardcoded auth.dev, so rehearsing a deployment meant editing a
tracked file and losing the change on the next pull. Read the three domain
values from the environment with the default kept at auth.dev, so a plain
docker compose up behaves exactly as before.
2026-10-03 05:17:06 +02:00
Daniel Noulet
84b64310fe doc: modified client-login.md 2026-10-03 04:41:03 +02:00
Daniel Noulet
b011701522 adding certificate 2026-10-03 00:45:34 +02:00
83 changed files with 10177 additions and 235 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

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

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ bin/
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

@ -1,6 +1,7 @@
SHELL := /bin/sh
GO_LOOSE_ADDR ?= :8081
GO_LOOSE_PORT ?= 8081
GO_LOOSE_BASE_URL ?= http://localhost:$(GO_LOOSE_PORT)
GO_LOOSE_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose?sslmode=disable
GO_LOOSE_SESSION_SECRET ?= local-development-secret-change-me-now
@ -9,28 +10,87 @@ 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 run compose-up compose-down fmt vet
.PHONY: build test integration-db integration-test behaviour-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet
build:
go build ./...
# The database-backed suites drop every table they touch, so `test` runs without
# them. Each suite skips itself when its variable is empty, which keeps a
# developer without PostgreSQL from being blocked; `integration-test` sets them.
test:
go test -race ./...
GO_LOOSE_TEST_DATABASE_URL= GO_LOOSE_TEST_SERVER_DATABASE_URL= go test -race ./...
# 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
# The behaviour suite drives the whole application over HTTP and needs the Google
# client credentials for the installer. It reads ../google_secrets.json, or the
# variables below, and skips when neither is present.
GO_LOOSE_BEHAVIOUR_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_behaviour_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
-docker compose exec -T postgres createdb -U goloose goloose_behaviour_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/ ./internal/server/ -count=1
# Installs the application into an empty database, walks every flow, restarts the
# process, and checks that everything is still there. Leaves the database empty
# again so the next run starts from nothing.
behaviour-test:
GO_LOOSE_BEHAVIOUR=1 go test ./internal/behaviour/ -count=1 -v
-docker compose exec -T postgres psql -q -U goloose -d goloose_behaviour_test \
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
run:
go run ./cmd/go-loose
# The variables above are exported for `make run`, but compose reads
# GO_LOOSE_BASE_URL too and would inherit the local http://localhost:8081 value,
# which then overrides the https:// defaults in docker-compose.yaml. Clearing it
# for the compose targets lets compose apply its own defaults.
compose-up:
docker compose up --build
GO_LOOSE_BASE_URL= docker compose up --build
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

@ -60,10 +60,15 @@ Production must place Go Loose behind TLS, use a secret manager, set `GO_LOOSE_B
- [First-run installation, waiting room, and demo accounts](docs/installation.md)
- [Go middleware integration](docs/integration.md)
- [Client application login](docs/client-login.md)
- [Tenant roles, capabilities, and profile pictures](docs/roles.md)
- [Database user authentication and local credentials](docs/password-authentication.md)
- [Operations, migrations, and OIDC](docs/operations.md)
- [Coding standards](docs/coding-standards.md)
## Profile
Every management session can open `/profile` to set a display name and a profile picture. Pictures are stored in PostgreSQL and served from a public, unguessable URL that client applications can embed directly. Display names stop being overwritten by the identity provider once the user customizes them. See [roles and capabilities](docs/roles.md).
## Development
```bash
@ -77,3 +82,37 @@ make build
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 test` runs the unit tests and needs nothing. `make integration-db` creates the 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.
### Behaviour suite
`make behaviour-test` drives the whole application over real HTTP against a real PostgreSQL database. It resets the database, installs the application through the web installer using the Google client credentials in `google_secrets.json`, signs in, walks every flow, then replaces the process and checks that the installation, tenants, applications, contracts, roles, sessions, profile, and picture are all still there. It resets the database again afterwards.
The suite is skipped by `go test ./...` because it needs that credentials file and a scratch database. Set `GO_LOOSE_BEHAVIOUR_CLIENT_ID` and `GO_LOOSE_BEHAVIOUR_CLIENT_SECRET` instead of the file if you prefer. The one step it cannot automate is Google's interactive consent screen: it verifies the redirect to Google carries the installed client id and redirect URI, but completing the round trip needs a real Google account.
Phases live in `internal/behaviour`, one file per area, and share a single ordered journey so each step can use what the previous one created.
### 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.

View file

@ -41,10 +41,28 @@ type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
// Role is the user's membership role in the owning tenant, for example
// user, operator, developer, admin, or owner.
Role string `json:"role"`
// Permissions lists the capability names granted by Role, such as
// "console.read", "keys.manage", or "profile.edit". Gate application
// features on these instead of comparing role names.
Permissions []string `json:"permissions"`
}
// Can reports whether the role behind this session grants a capability.
func (u User) Can(permission string) bool {
for _, granted := range u.Permissions {
if granted == permission {
return true
}
}
return false
}
type userContextKey struct{}

View file

@ -0,0 +1,23 @@
apiVersion: v2
name: go-loose
description: Authentication and authorization service for urpi platform. Provides OIDC, session management, and tenant isolation.
type: application
version: 1.0.0
appVersion: "1.0.0"
kubeVersion: ">=1.28.0-0"
keywords:
- authentication
- authorization
- oidc
- identity
- sso
icon: https://raw.githubusercontent.com/urpi/go-loose/main/docs/assets/logo.svg
home: https://github.com/urpi/go-loose
sources:
- https://github.com/urpi/go-loose
maintainers:
- name: urpi platform team
url: https://github.com/urpi
annotations:
category: Security
licenses: MIT

View file

@ -0,0 +1,19 @@
go-loose has been deployed to namespace {{ .Release.Namespace }}.
Access the service:
https://auth.urpi.be
Health check:
curl https://auth.urpi.be/healthz
PostgreSQL is deployed as a sub-chart. Credentials are in:
kubectl get secret {{ include "go-loose.fullname" . }}-postgresql -n {{ .Release.Namespace }} -o jsonpath='{.data.postgres-password}' | base64 -d
Environment variables are stored in secret:
kubectl get secret {{ include "go-loose.fullname" . }}-env -n {{ .Release.Namespace }} -o yaml
To update secrets (SESSION_SECRET, DATABASE_URL):
kubectl patch secret {{ include "go-loose.fullname" . }}-env -n {{ .Release.Namespace }} -p '{"stringData":{"GO_LOOSE_SESSION_SECRET":"new-secret"}}'
kubectl rollout restart deployment {{ include "go-loose.fullname" . }} -n {{ .Release.Namespace }}
Default admin user is created on first startup via GO_LOOSE_LOCAL_LOGIN=true.

View file

@ -0,0 +1,60 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "go-loose.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "go-loose.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "go-loose.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "go-loose.labels" -}}
helm.sh/chart: {{ include "go-loose.chart" . }}
{{ include "go-loose.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "go-loose.selectorLabels" -}}
app.kubernetes.io/name: {{ include "go-loose.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "go-loose.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "go-loose.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-loose.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "go-loose.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "go-loose.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-loose.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "go-loose.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- range $key, $value := .Values.envSecrets }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,23 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "go-loose.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "go-loose.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "go-loose.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

View file

@ -0,0 +1,36 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "go-loose.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "go-loose.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ include "go-loose.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,12 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "go-loose.fullname" . }}-env
namespace: {{ .Release.Namespace }}
labels:
{{- include "go-loose.labels" . | nindent 4 }}
type: Opaque
stringData:
{{- range $key, $value := .Values.envSecrets }}
{{ $key }}: {{ $value | quote }}
{{- end }}

View file

@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "go-loose.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "go-loose.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "go-loose.selectorLabels" . | nindent 4 }}

View file

@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "go-loose.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "go-loose.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,95 @@
# PRD values for go-loose
replicaCount: 1
imagePullSecrets:
- name: nexus-registry
image:
repository: nexus-registry.urpi.be/docker-hosted/go-loose
tag: "0.0.1"
pullPolicy: Always
service:
type: ClusterIP
port: 8080
targetPort: 8080
ingress:
enabled: true
className: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/ssl-redirect: "true"
# nginx.ingress.kubernetes.io/proxy-body-size: "10m"
hosts:
- host: auth.urpi.be
paths:
- path: /
pathType: Prefix
tls:
- secretName: go-loose-tls
hosts:
- auth.urpi.be
certManager:
enabled: true
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
postgresql:
enabled: true
auth:
database: goloose
username: goloose
password: ""
primary:
persistence:
enabled: true
size: 10Gi
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 250m
memory: 512Mi
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 250m
memory: 512Mi
env:
GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "https://auth.urpi.be"
GO_LOOSE_AUTH_DOMAIN: "auth.urpi.be"
GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.urpi.be/auth/callback"
GO_LOOSE_DEV_LOGIN: "false"
GO_LOOSE_LOCAL_LOGIN: "true"
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
envSecrets:
GO_LOOSE_DATABASE_URL: "postgres://goloose:changeme@go-loose-postgresql:5432/goloose?sslmode=disable"
GO_LOOSE_SESSION_SECRET: "changeme-generate-secure-random"
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3

View file

@ -0,0 +1,128 @@
replicaCount: 1
imagePullSecrets:
- name: nexus-registry
image:
repository: nexus.urpi.be/docker-hosted/go-loose
tag: "0.0.1"
pullPolicy: Always
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
service:
type: ClusterIP
port: 8080
targetPort: 8080
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
hosts:
- host: auth.urpi.be
paths:
- path: /
pathType: Prefix
tls:
- secretName: go-loose-tls
hosts:
- auth.urpi.be
certManager:
enabled: true
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
postgresql:
enabled: true
auth:
database: goloose
username: goloose
password: changeme
primary:
persistence:
enabled: true
size: 5Gi
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
env:
GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "https://auth.urpi.be"
GO_LOOSE_AUTH_DOMAIN: "auth.urpi.be"
GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.urpi.be/auth/callback"
GO_LOOSE_DEV_LOGIN: "false"
GO_LOOSE_LOCAL_LOGIN: "true"
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
envSecrets:
GO_LOOSE_DATABASE_URL: ""
GO_LOOSE_SESSION_SECRET: ""
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 5
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
nodeSelector: {}
tolerations: []
affinity: {}

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:

65
docker-compose.tst.yaml Normal file
View file

@ -0,0 +1,65 @@
# TST stack: docker compose -f docker-compose.tst up --build
#
# Go Loose for testing environment. Similar to dev but pointed at tst services.
#
services:
postgres:
image: postgres:17-alpine
ports:
- "${GO_LOOSE_POSTGRES_PORT:-5433}:5432"
environment:
POSTGRES_DB: goloose
POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose
volumes:
- db-data-tst:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
interval: 5s
timeout: 3s
retries: 10
networks:
- go_loose_tst_network
go-loose:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-http://tst-intern-urpi.be:8080}"
GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-auth-tst.intern-urpi.be}"
GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable"
GO_LOOSE_SESSION_SECRET: "${GO_LOOSE_SESSION_SECRET:-tst-session-secret-change-me}"
GO_LOOSE_DEV_LOGIN: "false"
GO_LOOSE_LOCAL_LOGIN: "false"
GO_LOOSE_LOCAL_ADMIN_EMAIL: "admin@tst.goloose.local"
GO_LOOSE_LOCAL_ADMIN_PASSWORD: "${GO_LOOSE_LOCAL_ADMIN_PASSWORD:-}"
GO_LOOSE_ALLOWED_EMAIL_DOMAINS: ""
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
GO_LOOSE_BOOTSTRAP_TENANT: "nmbs"
GO_LOOSE_BOOTSTRAP_APP: "guess"
GO_LOOSE_OIDC_ISSUER: "https://accounts.google.com"
GO_LOOSE_OIDC_CLIENT_ID: "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com"
GO_LOOSE_OIDC_CLIENT_SECRET: "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"
GO_LOOSE_OIDC_REDIRECT_URL: "https://auth-tst.intern-urpi.be/auth/callback"
ports:
- "${GO_LOOSE_TST_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_tst_network
networks:
go_loose_tst_network:
volumes:
db-data-tst:

View file

@ -8,7 +8,9 @@ services:
POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose
volumes:
- postgres-data2:/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
@ -23,14 +25,17 @@ services:
condition: service_healthy
environment:
GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "https://auth.dev"
GO_LOOSE_AUTH_DOMAIN: "auth.dev"
GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.dev/auth/callback"
GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-https://auth.urpi.be}"
GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-auth.urpi.be}"
GO_LOOSE_OIDC_REDIRECT_URL: "${GO_LOOSE_OIDC_REDIRECT_URL:-https://auth.urpi.be/auth/callback}"
GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable"
GO_LOOSE_SESSION_SECRET: "local-development-secret-change-me-now"
GO_LOOSE_DEV_LOGIN: "false"
GO_LOOSE_LOCAL_LOGIN: "true"
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
SSL_CERT_FILE: /certs/local-ca.crt
volumes:
- ${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro
expose:
- "8080"
healthcheck:
@ -46,7 +51,8 @@ services:
- go-loose
volumes:
postgres-data2:
db-data:
networks:
go_loose_network:
local_dev_edge:

View file

@ -120,6 +120,45 @@ Request → client middleware → GET /connect/userinfo → protected handler
The authorization code expires after two minutes and can be used once. Redirect URIs must match exactly. Removing a user's application grant immediately makes `/connect/userinfo` reject existing sessions. Logout revokes the central session and clears the local cookie.
## Role and profile picture
Both `/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application:
```json
{
"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",
"users.read",
"applications.manage",
"keys.manage",
"contracts.manage",
"users.manage",
"profile.edit"
]
}
```
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.
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
- Use TLS for both Go Loose and the client application.
@ -127,7 +166,74 @@ The authorization code expires after two minutes and can be used once. Redirect
- 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.
- **A `user_application_access` grant is the only thing that admits a person to your
application.** The system administrator is not an exception: they own every tenant,
which is what lets them administer it, but ownership does not sign them in here.
Nobody, administrator included, reaches your application without a grant recorded
against your `client_id`. If support needs to see the application as a user, grant
it deliberately from **Users & access** so it is attributable and revocable, rather
than assuming the administrator is already inside.
## 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
identity domain `auth.dev`:
| Application | Tenant | Callback URL |
|---|---|---|
| Go Guess | `nmbs` | `https://nmbs.guess.dev/api/auth/callback` |
| Go Guess | `ypto` | `https://ypto.guess.dev/api/auth/callback` |
| Go Tell | `tell` | `https://tell.dev/api/auth/callback` |
Go Guess has one application per tenant because it serves one host per tenant.
Go Tell has a single application: it is served from one host with no tenant
routing, so one client covers every request.
Client secrets are shown once when an application is configured. The Compose
stack reads them from a gitignored `.env`, and the Kubernetes releases receive
them through their release secret. Go Loose hashes stored secrets, so a lost
secret can only be replaced, never recovered.
Cluster clients cannot reach the workstation Traefik that serves `auth.dev`.
Both releases therefore mount the platform CA and expect a reachable identity
address, and a `502` on `/api/auth/callback` means the pod cannot reach Go
Loose rather than that the client is misconfigured.
## Troubleshooting `403`

View file

@ -8,6 +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 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

@ -33,7 +33,9 @@ Later requests to `/install` redirect to login, and duplicate submissions are re
## First administrator and waiting room
The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant and application that exists at that moment.
The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant that exists at that moment.
Owning a tenant is what lets the administrator administer it; it does not let them sign in to the applications running inside it. Access to a client application is always an explicit grant, so an administrator who wants to use one attaches themselves from **Users & access** like anybody else. The grant is visible, attributable and revocable, and revoking it signs them out of that application without affecting their console access.
Later first-time SSO users are created without tenant membership. Their management page shows only the waiting room. A system administrator approves a waiting user from **Users & access**, selecting:
@ -78,3 +80,14 @@ An application must first have exact redirect URIs and a generated client secret
Existing deployments may continue to supply all three `GO_LOOSE_OIDC_ISSUER`, `GO_LOOSE_OIDC_CLIENT_ID`, and `GO_LOOSE_OIDC_CLIENT_SECRET` variables. Go Loose treats that as an already configured deployment and bypasses the installer. Supplying `GO_LOOSE_LOCAL_LOGIN=true` together with a non-empty `GO_LOOSE_LOCAL_ADMIN_PASSWORD` does the same for a database-only development deployment.
For new installations, prefer the web installer. `GO_LOOSE_LOCAL_LOGIN=true` without a bootstrap password enables seeded and invited database users but does not bypass installation.
Two rules make that second mode usable rather than merely possible:
- The local administrator claims the system administrator role when the installation has none. Creating tenants and approving waiting-room users both require that role, and only a first Google sign-in otherwise grants it, so a database-only deployment would otherwise have nobody able to do either. The claim is a no-op once any system administrator exists, so an installation already owned by a Google account is untouched.
- Bootstrapping grants ownership of a tenant only while that tenant has no owner. It deliberately does not care whether the tenant has other members, because the demonstration seed creates tenants holding administrators but no owner; 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 never taken over.
Supplying `GO_LOOSE_LOCAL_ADMIN_PASSWORD` after a Google account has already claimed the role changes nothing.
## Verifying an installation
`make behaviour-test` installs the application into an empty database, signs in, walks every flow, and then restarts the process to confirm that everything was persisted rather than held in memory. See the [README](../README.md#behaviour-suite) for what it needs.

View file

@ -37,6 +37,8 @@ The provider must return `sub`, `email`, and preferably `name` claims.
Migration files live in `internal/database/migrations` and are embedded into the binary. Files run lexicographically once, in individual transactions.
A migration run holds a session-level advisory lock, so several replicas may start at the same time against one database. Without it the run races with itself: PostgreSQL's `CREATE TABLE IF NOT EXISTS` is not race free and can fail with a duplicate `pg_type` row, and two replicas can try to apply the same file. The second replica waits and then finds the work already recorded.
Create upgrades with an immutable numbered file:
```text

91
docs/roles.md Normal file
View file

@ -0,0 +1,91 @@
# Roles, permissions, and profiles
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.
## Data model
- `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`.
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 |
| --- | --- | --- | --- | --- | --- | --- |
| `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.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 |
`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.
## Managing roles
`/roles` is the console page. The API is:
| 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 |
Two rules keep this from becoming a privilege-escalation hole:
- **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.
`role_permissions.permission` is a foreign key onto `permissions.name`, so a role cannot reference a capability that is not in the catalog.
## Go side
`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.
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 picture, independent of tenant role.
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 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.
## 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 vocabulary.
`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`.

143
internal/avatar/avatar.go Normal file
View file

@ -0,0 +1,143 @@
// Package avatar validates and addresses profile pictures stored in PostgreSQL.
//
// Images are kept as their uploaded bytes. Go Loose never re-encodes them; it
// sniffs the format, bounds the decoded dimensions to reject decompression
// bombs, and serves the bytes back with the sniffed content type behind an
// unguessable key so that other applications can embed the picture with a plain
// image tag.
package avatar
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"strings"
)
const (
// MaxBytes bounds an uploaded picture. Base64 inflates it by 4/3, so a
// request carrying an avatar must allow roughly MaxBytes*4/3 bytes.
MaxBytes = 2 << 20
// MaxDimension bounds decoded pixels so that a small upload cannot expand
// into an unreasonable image when a client renders it.
MaxDimension = 4096
)
var (
ErrEmpty = errors.New("the picture is empty")
ErrTooLarge = fmt.Errorf("the picture must be %d KiB or smaller", MaxBytes/1024)
ErrUnsupported = errors.New("the picture must be a JPEG, PNG, or GIF image")
ErrDimensions = fmt.Errorf("the picture must not exceed %d by %d pixels", MaxDimension, MaxDimension)
ErrEncoding = errors.New("the picture is not valid base64")
)
// allowed maps the format names reported by image.DecodeConfig to content types.
// Only the decoders registered above are accepted, so an unsupported upload
// fails validation instead of being stored and served.
var allowed = map[string]string{
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
}
// Parse decodes a base64 payload, with or without a data URL prefix, and
// returns the image bytes together with the sniffed content type.
func Parse(value string) ([]byte, string, error) {
payload, declared := trimDataURL(value)
if payload == "" {
return nil, "", ErrEmpty
}
if base64.StdEncoding.DecodedLen(len(payload)) > MaxBytes {
return nil, "", ErrTooLarge
}
data, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, "", ErrEncoding
}
contentType, err := Inspect(data)
if err != nil {
return nil, "", err
}
if declared != "" && !strings.EqualFold(declared, contentType) {
return nil, "", ErrUnsupported
}
return data, contentType, nil
}
// Inspect validates raw image bytes and returns their content type.
func Inspect(data []byte) (string, error) {
if len(data) == 0 {
return "", ErrEmpty
}
if len(data) > MaxBytes {
return "", ErrTooLarge
}
decoded, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", ErrUnsupported
}
contentType, known := allowed[format]
if !known {
return "", ErrUnsupported
}
if decoded.Width > MaxDimension || decoded.Height > MaxDimension {
return "", ErrDimensions
}
return contentType, nil
}
// NewKey returns an unguessable locator for a stored picture. The key is a
// capability-free random value: it authorizes reading a picture that is
// intended to be public, not access to anything else about the user.
func NewKey() (string, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return "", fmt.Errorf("generate avatar key: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
// ValidKey reports whether a key has the shape produced by NewKey, which keeps
// obviously malformed lookups out of the database.
func ValidKey(key string) bool {
if len(key) != 43 {
return false
}
_, err := base64.RawURLEncoding.DecodeString(key)
return err == nil
}
// ETag returns a strong validator derived from the stored bytes so that clients
// and proxies can cache the picture and revalidate cheaply.
func ETag(data []byte) string {
sum := sha256.Sum256(data)
return `"` + hex.EncodeToString(sum[:16]) + `"`
}
func trimDataURL(value string) (payload, mediaType string) {
value = strings.TrimSpace(value)
if !strings.HasPrefix(strings.ToLower(value), "data:") {
return value, ""
}
comma := strings.IndexByte(value, ',')
if comma < 0 {
return "", ""
}
header := value[len("data:"):comma]
parameters := strings.Split(header, ";")
mediaType = strings.ToLower(strings.TrimSpace(parameters[0]))
for _, parameter := range parameters[1:] {
if strings.EqualFold(strings.TrimSpace(parameter), "base64") {
return strings.TrimSpace(value[comma+1:]), mediaType
}
}
return "", ""
}

View file

@ -0,0 +1,172 @@
package avatar
import (
"bytes"
"encoding/base64"
"errors"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
)
func samplePNG(t *testing.T, width, height int) []byte {
t.Helper()
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
canvas.Set(0, 0, color.RGBA{R: 125, G: 207, B: 255, A: 255})
buffer := &bytes.Buffer{}
if err := png.Encode(buffer, canvas); err != nil {
t.Fatalf("encode png: %v", err)
}
return buffer.Bytes()
}
func encode(t *testing.T, format string, width, height int) []byte {
t.Helper()
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
buffer := &bytes.Buffer{}
var err error
switch format {
case "jpeg":
err = jpeg.Encode(buffer, canvas, nil)
case "gif":
err = gif.Encode(buffer, canvas, nil)
default:
err = png.Encode(buffer, canvas)
}
if err != nil {
t.Fatalf("encode %s: %v", format, err)
}
return buffer.Bytes()
}
func TestParseAcceptsSupportedFormats(t *testing.T) {
for _, format := range []string{"png", "jpeg", "gif"} {
t.Run(format, func(t *testing.T) {
raw := encode(t, format, 8, 8)
data, contentType, err := Parse(base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if !bytes.Equal(data, raw) {
t.Fatal("Parse() did not return the uploaded bytes")
}
if contentType != "image/"+map[string]string{"png": "png", "jpeg": "jpeg", "gif": "gif"}[format] {
t.Fatalf("content type = %q", contentType)
}
})
}
}
func TestParseAcceptsDataURL(t *testing.T) {
raw := samplePNG(t, 4, 4)
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
data, contentType, err := Parse(dataURL)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if len(data) != len(raw) || contentType != "image/png" {
t.Fatalf("Parse() = %d bytes of %q", len(data), contentType)
}
}
func TestParseRejectsMismatchedDataURLMediaType(t *testing.T) {
raw := samplePNG(t, 4, 4)
dataURL := "data:image/gif;base64," + base64.StdEncoding.EncodeToString(raw)
if _, _, err := Parse(dataURL); !errors.Is(err, ErrUnsupported) {
t.Fatalf("error = %v, want %v", err, ErrUnsupported)
}
}
func TestParseRejectsNonBase64DataURL(t *testing.T) {
raw := samplePNG(t, 4, 4)
dataURL := "data:image/png," + base64.StdEncoding.EncodeToString(raw)
if _, _, err := Parse(dataURL); !errors.Is(err, ErrEmpty) {
t.Fatalf("error = %v, want %v", err, ErrEmpty)
}
}
func TestParseRejectsMalformedAndUnsupportedInput(t *testing.T) {
tests := []struct {
name string
value string
want error
}{
{name: "empty", value: " ", want: ErrEmpty},
{name: "not base64", value: "!!!not base64!!!", want: ErrEncoding},
{name: "plain text", value: base64.StdEncoding.EncodeToString([]byte("hello world, not an image")), want: ErrUnsupported},
{name: "svg", value: base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)), want: ErrUnsupported},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := Parse(test.value); !errors.Is(err, test.want) {
t.Fatalf("error = %v, want %v", err, test.want)
}
})
}
}
func TestParseRejectsOversizedUploads(t *testing.T) {
huge := base64.StdEncoding.EncodeToString(make([]byte, MaxBytes+1))
if _, _, err := Parse(huge); !errors.Is(err, ErrTooLarge) {
t.Fatalf("error = %v, want %v", err, ErrTooLarge)
}
}
func TestInspectRejectsDecompressionBombs(t *testing.T) {
oversized := samplePNG(t, 1, MaxDimension+1)
if _, err := Inspect(oversized); !errors.Is(err, ErrDimensions) {
t.Fatalf("error = %v, want %v", err, ErrDimensions)
}
raw := samplePNG(t, MaxDimension, MaxDimension)
if _, err := Inspect(raw); err != nil {
t.Fatalf("Inspect() rejected the maximum size: %v", err)
}
}
func TestInspectRejectsEmptyBytes(t *testing.T) {
if _, err := Inspect(nil); !errors.Is(err, ErrEmpty) {
t.Fatalf("error = %v, want %v", err, ErrEmpty)
}
}
func TestNewKeyIsUnguessableAndValid(t *testing.T) {
seen := make(map[string]bool)
for range 100 {
key, err := NewKey()
if err != nil {
t.Fatalf("NewKey() error = %v", err)
}
if !ValidKey(key) {
t.Fatalf("NewKey() produced an invalid key %q", key)
}
if seen[key] {
t.Fatal("NewKey() repeated a key")
}
seen[key] = true
}
}
func TestValidKeyRejectsMalformedLocators(t *testing.T) {
tests := []string{"", "short", "../etc/passwd", string(bytes.Repeat([]byte("a"), 44)), "a/b+c="}
for _, test := range tests {
if ValidKey(test) {
t.Fatalf("ValidKey(%q) = true", test)
}
}
}
func TestETagIsStableAndContentDerived(t *testing.T) {
first, second := samplePNG(t, 4, 4), samplePNG(t, 5, 5)
if ETag(first) != ETag(first) {
t.Fatal("ETag is not stable for identical content")
}
if ETag(first) == ETag(second) {
t.Fatal("ETag must change when the content changes")
}
if tag := ETag(first); len(tag) < 3 || tag[0] != '"' || tag[len(tag)-1] != '"' {
t.Fatalf("ETag = %q, want a quoted validator", tag)
}
}

View file

@ -0,0 +1,191 @@
package behaviour
import (
"fmt"
"net/http"
"strings"
"testing"
)
// unknownButWellFormed has the shape of a real key so that it reaches the store
// and is rejected as unknown, rather than being turned away by format validation.
var unknownButWellFormed = "gl_unknown_" + strings.Repeat("A", 40)
// applicationPhases registers an application, issues API keys, and exercises the
// authorization endpoint that every Go middleware integration depends on.
func applicationPhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
phase(t, "an application is registered with allowed hosts", func(t *testing.T) {
type application struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
AllowedHosts []string `json:"allowed_hosts"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "behaviour-app", "name": "Behaviour App",
"description": "created by the behaviour suite", "allowed_hosts": []string{"app.behaviour.test"},
})
created := decode[application](t, response, http.StatusCreated)
if created.Slug != "behaviour-app" {
t.Errorf("slug = %q", created.Slug)
}
j.applicationID = created.ID
})
phase(t, "an allowed host carrying a scheme or port is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "bad-hosts", "name": "Bad Hosts",
"allowed_hosts": []string{"https://app.behaviour.test"},
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "an application without a tenant or a valid slug is refused", func(t *testing.T) {
for _, payload := range []map[string]any{
{"slug": "no-tenant", "name": "No Tenant"},
{"tenant_id": j.scopedTenantID, "slug": "Bad Slug", "name": "Bad Slug"},
{"tenant_id": j.scopedTenantID, "slug": "", "name": "Empty Slug"},
} {
response := j.admin.json(http.MethodPost, host, "/api/v1/applications", payload)
statusOnly(t, response, http.StatusBadRequest)
}
})
phase(t, "an API key is issued and shown exactly once", func(t *testing.T) {
type issued struct {
Key struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"key"`
Secret string `json:"secret"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/keys", map[string]any{
"application_id": j.applicationID, "name": "integration key",
})
result := decode[issued](t, response, http.StatusCreated)
if result.Secret == "" {
t.Fatal("no secret returned")
}
if result.Key.ID == "" {
t.Error("no key id returned")
}
j.apiKey, j.apiKeyID = result.Secret, result.Key.ID
// The dashboard must never expose the secret again.
type keyListing struct {
Keys []map[string]any `json:"keys"`
}
listing := decode[keyListing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK)
for _, key := range listing.Keys {
for field, value := range key {
if text, ok := value.(string); ok && text == j.apiKey {
t.Errorf("the dashboard leaked the secret in %q", field)
}
}
}
})
phase(t, "a key in the wrong format is refused", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
nil, map[string]string{"X-API-Key": "not-a-key"})
// The authorize endpoint is reached by application middleware, not by a
// browser session, so no session is involved here.
statusOnly(t, response, http.StatusUnauthorized)
})
phase(t, "an unknown key is denied", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)),
map[string]string{"X-API-Key": unknownButWellFormed, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "a valid key is authorized for its own tenant, application, and host", func(t *testing.T) {
type decision struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
TenantSlug string `json:"tenant_slug"`
AppSlug string `json:"application_slug"`
APIKeyName string `json:"api_key_name"`
}
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
result := decode[decision](t, response, http.StatusOK)
if !result.Allowed {
t.Errorf("allowed = false, reason %q", result.Reason)
}
if result.TenantSlug != "behaviour" || result.AppSlug != "behaviour-app" {
t.Errorf("identity = %s/%s", result.TenantSlug, result.AppSlug)
}
if result.APIKeyName != "integration key" {
t.Errorf("key name = %q", result.APIKeyName)
}
})
phase(t, "a host outside the allow list is denied", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(`{"tenant":"{j.scopedTenant}","application":"behaviour-app","method":"GET","path":"/api/x","host":"evil.test"}`),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "another tenant is not reachable with this key", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.tenantSlug)),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "the key can also be presented as a bearer token", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)),
map[string]string{"Authorization": "Bearer " + j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusOK)
})
phase(t, "an incomplete authorization request is refused", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(`{"method":"GET"}`),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "every authorization attempt is recorded", func(t *testing.T) {
var total int
if err := j.h.db.QueryRowContext(t.Context(),
`SELECT count(*) FROM authorization_events WHERE api_key_id = $1`, j.apiKeyID).Scan(&total); err != nil {
t.Fatalf("count authorization events: %v", err)
}
if total < 4 {
t.Errorf("authorization events = %d, want the attempts above to be recorded", total)
}
})
phase(t, "revoking the key stops it working immediately", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.adminHost(),
"/api/v1/keys/"+j.apiKeyID+"/revoke", nil, map[string]string{
"Origin": "http://" + j.adminHost(), "Content-Length": "0",
})
if response.StatusCode != http.StatusNoContent {
t.Fatalf("revoke = %d: %s", response.StatusCode, bodyOf(t, response))
}
response.Body.Close()
response = j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "revoking an unknown key reports not found", func(t *testing.T) {
response := j.admin.request(http.MethodPost, j.adminHost(),
"/api/v1/keys/00000000-0000-0000-0000-000000000000/revoke", nil, map[string]string{
"Origin": "http://" + j.adminHost(), "Content-Length": "0",
})
statusOnly(t, response, http.StatusNotFound)
})
}

View file

@ -0,0 +1,464 @@
package behaviour
import (
"encoding/base64"
"net/http"
"net/url"
"strings"
"testing"
)
// clientPhases walks the whole hosted-login dance a tenant application performs:
// it configures a client, sends a member to the provider, comes back with a code,
// exchanges it for a bearer token, reads the identity, and signs out again.
func clientLoginPhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
redirectURI := "https://app.behaviour.test/auth/callback"
phase(t, "a redirect URI that is not HTTPS is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host,
"/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{
"redirect_uris": []string{"http://app.behaviour.test/callback"},
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a redirect URI carrying a fragment is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host,
"/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{
"redirect_uris": []string{"https://app.behaviour.test/callback#token"},
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "duplicate redirect URIs are refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host,
"/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{
"redirect_uris": []string{redirectURI, redirectURI},
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "client login is configured and the application becomes ready", func(t *testing.T) {
type configured struct {
Application struct {
ID string `json:"id"`
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
ClientReady bool `json:"client_ready"`
} `json:"application"`
ClientSecret string `json:"client_secret"`
}
response := j.admin.json(http.MethodPost, host,
"/api/v1/applications/"+j.applicationID+"/client-config", map[string]any{
"redirect_uris": []string{redirectURI},
})
result := decode[configured](t, response, http.StatusOK)
if result.ClientSecret == "" {
t.Fatal("no client secret returned")
}
if result.Application.ClientID == "" {
t.Error("no client id assigned")
}
if !result.Application.ClientReady {
t.Error("the application is not reported as ready for client login")
}
if len(result.Application.RedirectURIs) != 1 {
t.Errorf("redirect URIs = %v", result.Application.RedirectURIs)
}
j.clientID, j.clientSecret = result.Application.ClientID, result.ClientSecret
})
phase(t, "an unknown client is refused at the authorize endpoint", func(t *testing.T) {
browser := j.browser()
response := browser.get(host, "/connect/authorize?response_type=code&client_id=glc_unknown"+
"&redirect_uri="+url.QueryEscape(redirectURI)+"&state=xyz", nil)
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "incomplete authorize parameters are refused", func(t *testing.T) {
browser := j.browser()
for _, query := range []string{
"client_id=" + j.clientID,
"response_type=token&client_id=" + j.clientID + "&state=s",
"response_type=code&client_id=" + j.clientID + "&redirect_uri=" + url.QueryEscape(redirectURI),
} {
response := browser.get(host, "/connect/authorize?"+query, nil)
if response.StatusCode != http.StatusBadRequest {
t.Errorf("authorize with %q = %d, want 400", query, response.StatusCode)
}
response.Body.Close()
}
})
phase(t, "an apex request is redirected onto the tenant hostname", func(t *testing.T) {
browser := j.browser()
query := "response_type=code&client_id=" + j.clientID +
"&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=tenant-host"
response := browser.get(j.h.apex(), "/connect/authorize?"+query, nil)
if response.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want 307: %s", response.StatusCode, bodyOf(t, response))
}
if !strings.Contains(location(t, response), j.h.tenantHost(j.scopedTenant)) {
t.Errorf("redirect = %q, want the tenant host", location(t, response))
}
})
phase(t, "a signed-out visitor is sent to the tenant sign-in page", func(t *testing.T) {
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
query := "response_type=code&client_id=" + j.clientID +
"&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=signin"
response := browser.get(tenantHost, "/connect/authorize?"+query, nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response))
}
target := location(t, response)
if !strings.Contains(target, "/client/login") {
t.Fatalf("redirect = %q, want the client sign-in page", target)
}
page := bodyOf(t, browser.get(tenantHost, target, nil))
if !strings.Contains(page, "csrf_token") {
t.Errorf("the sign-in page has no form: %.200s", page)
}
})
phase(t, "a member without an application grant is refused", func(t *testing.T) {
// This person is a member of the tenant but was never granted the
// application, which is the distinction the endpoint has to enforce.
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
query := "response_type=code&client_id=" + j.clientID +
"&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=grant"
response := browser.get(tenantHost, "/connect/authorize?"+query, nil)
target := location(t, response)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, target, nil)))
response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword, "return": returnParam(t, target),
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.get(tenantHost, "/connect/authorize?"+query, nil)
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "a granted member completes the flow and receives a session token", func(t *testing.T) {
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
query := "response_type=code&client_id=" + j.clientID +
"&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=happy"
response := browser.get(tenantHost, "/connect/authorize?"+query, nil)
target := location(t, response)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, target, nil)))
response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, "return": returnParam(t, target),
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
// Back to the authorize endpoint, now with a session: a code comes back.
response = browser.get(tenantHost, "/connect/authorize?"+query, nil)
if response.StatusCode != http.StatusFound {
t.Fatalf("authorize = %d, want 302: %s", response.StatusCode, bodyOf(t, response))
}
callback, err := url.Parse(location(t, response))
if err != nil {
t.Fatalf("parse callback: %v", err)
}
if callback.Host != "app.behaviour.test" {
t.Errorf("callback host = %q", callback.Host)
}
if callback.Query().Get("state") != "happy" {
t.Errorf("state = %q, want happy", callback.Query().Get("state"))
}
code := callback.Query().Get("code")
if code == "" {
t.Fatal("no authorization code issued")
}
// The application exchanges the code for a token using HTTP Basic auth.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
TenantSlug string `json:"tenant_slug"`
Application string `json:"application"`
} `json:"user"`
}
exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token",
strings.NewReader(url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI},
}.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret),
})
granted := decode[tokenResponse](t, exchange, http.StatusOK)
if granted.AccessToken == "" {
t.Fatal("no access token returned")
}
if granted.TokenType != "Bearer" {
t.Errorf("token_type = %q", granted.TokenType)
}
if granted.ExpiresIn <= 0 {
t.Errorf("expires_in = %d", granted.ExpiresIn)
}
if granted.User.Email != j.memberEmail {
t.Errorf("user email = %q, want %s", granted.User.Email, j.memberEmail)
}
if granted.User.TenantSlug != j.scopedTenant {
t.Errorf("tenant = %q, want %s", granted.User.TenantSlug, j.scopedTenant)
}
if granted.User.Role == "" || len(granted.User.Permissions) == 0 {
t.Errorf("the token payload carries no role or permissions: %+v", granted.User)
}
j.accessToken = granted.AccessToken
// The code is single use.
replay := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token",
strings.NewReader(url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI},
}.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret),
})
statusOnly(t, replay, http.StatusUnauthorized)
})
phase(t, "a second application is signed in without asking again", func(t *testing.T) {
// The single sign-on property: once Go Loose holds a session for this person
// in this tenant, another application on the same tenant must receive a
// code straight away, with no sign-in page in between.
type application struct {
ID string `json:"id"`
ClientID string `json:"client_id"`
RedirectURI []string `json:"redirect_uris"`
}
type setupResponse struct {
Application application `json:"application"`
Secret string `json:"client_secret"`
}
created := decode[application](t, j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "second-app", "name": "Second App",
"allowed_hosts": []string{"second.example.test"},
}), http.StatusCreated)
setup := decode[setupResponse](t, j.admin.json(http.MethodPost, host,
"/api/v1/applications/"+created.ID+"/client-config", map[string]any{
"redirect_uris": []string{"https://second.example.test/callback"},
}), http.StatusOK)
if setup.Application.ClientID == "" || setup.Secret == "" {
t.Fatal("the second application was not configured for client login")
}
// The member needs access to it, the same as any other application.
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.memberID+"/access", map[string]any{
"tenant_id": j.scopedTenantID, "role_id": j.viewerRoleID,
"application_ids": []string{j.applicationID, created.ID},
})
if access.StatusCode != http.StatusNoContent {
t.Fatalf("grant the second application = %d: %s", access.StatusCode, bodyOf(t, access))
}
// Sign in once, through the first application.
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
firstQuery := "response_type=code&client_id=" + j.clientID +
"&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=first"
signIn := location(t, browser.get(tenantHost, "/connect/authorize?"+firstQuery, nil))
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, signIn, nil)))
response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.memberEmail, "password": j.memberPassword,
"return": returnParam(t, signIn),
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
if response := browser.get(tenantHost, "/connect/authorize?"+firstQuery, nil); response.StatusCode != http.StatusFound {
t.Fatalf("first application = %d", response.StatusCode)
} else {
response.Body.Close()
}
// The second application must not see a sign-in page.
secondQuery := "response_type=code&client_id=" + setup.Application.ClientID +
"&redirect_uri=" + url.QueryEscape("https://second.example.test/callback") + "&state=second"
response = browser.get(tenantHost, "/connect/authorize?"+secondQuery, nil)
if response.StatusCode != http.StatusFound {
t.Fatalf("second application = %d, want a code with no prompt: %s",
response.StatusCode, bodyOf(t, response))
}
callback, err := url.Parse(location(t, response))
if err != nil {
t.Fatalf("parse callback: %v", err)
}
if callback.Host != "second.example.test" {
t.Errorf("callback host = %q", callback.Host)
}
if callback.Query().Get("code") == "" {
t.Error("no authorization code for the second application")
}
if callback.Query().Get("state") != "second" {
t.Errorf("state = %q", callback.Query().Get("state"))
}
// And it identifies the same person, with no further prompt.
exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token",
strings.NewReader(url.Values{
"grant_type": {"authorization_code"}, "code": {callback.Query().Get("code")},
"redirect_uri": {"https://second.example.test/callback"},
}.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + basicAuth(setup.Application.ClientID, setup.Secret),
})
type secondToken struct {
User struct {
Email string `json:"email"`
} `json:"user"`
}
granted := decode[secondToken](t, exchange, http.StatusOK)
if granted.User.Email != j.memberEmail {
t.Errorf("the second application saw %q, want %s", granted.User.Email, j.memberEmail)
}
})
phase(t, "the token identifies the user at the userinfo endpoint", func(t *testing.T) {
type identity struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Application string `json:"application"`
}
response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
user := decode[identity](t, response, http.StatusOK)
if user.Email != j.memberEmail {
t.Errorf("email = %q", user.Email)
}
if user.DisplayName == "" {
t.Error("no display name")
}
if user.Application != "behaviour-app" {
t.Errorf("application = %q", user.Application)
}
})
phase(t, "a wrong client secret cannot exchange a code", func(t *testing.T) {
response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token",
strings.NewReader(url.Values{
"grant_type": {"authorization_code"}, "code": {"glc_code_" + strings.Repeat("Q", 40)},
"redirect_uri": {redirectURI},
}.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + basicAuth(j.clientID, "glc_wrong_secret_value"),
})
statusOnly(t, response, http.StatusUnauthorized)
})
phase(t, "a token without basic authentication is refused", func(t *testing.T) {
response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token",
strings.NewReader("grant_type=authorization_code"), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
})
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.StatusCode)
}
response.Body.Close()
if response.Header.Get("WWW-Authenticate") == "" {
t.Error("no WWW-Authenticate challenge was sent")
}
})
phase(t, "userinfo refuses a request without a bearer token", func(t *testing.T) {
statusOnly(t, j.browser().get(j.h.apex(), "/connect/userinfo", nil), http.StatusUnauthorized)
})
phase(t, "userinfo refuses a made-up token", func(t *testing.T) {
response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer glc_session_" + strings.Repeat("Z", 43)})
statusOnly(t, response, http.StatusUnauthorized)
})
phase(t, "signing out of the client invalidates the token at once", func(t *testing.T) {
response := j.browser().request(http.MethodPost, j.h.apex(), "/connect/logout", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
if response.StatusCode != http.StatusNoContent {
t.Fatalf("logout = %d: %s", response.StatusCode, bodyOf(t, response))
}
response.Body.Close()
response = j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
statusOnly(t, response, http.StatusUnauthorized)
})
}
func basicAuth(user, password string) string {
return base64.StdEncoding.EncodeToString([]byte(user + ":" + password))
}
// returnParam extracts the authorize URL that the sign-in page will return to,
// which is carried in the sign-in page's own query string.
func returnParam(t *testing.T, signInURL string) string {
t.Helper()
parsed, err := url.Parse(signInURL)
if err != nil {
t.Fatalf("parse sign-in URL %q: %v", signInURL, err)
}
value := parsed.Query().Get("return")
if value == "" {
t.Fatalf("the sign-in URL carries no return target: %q", signInURL)
}
return value
}
// establishClientSession runs the whole hosted-login dance and returns the bearer
// token, so a phase can assert on the token's lifetime rather than on the flow.
func establishClientSession(t *testing.T, j *journey, state string) string {
t.Helper()
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
redirectURI := "https://app.behaviour.test/auth/callback"
query := "response_type=code&client_id=" + j.clientID +
"&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=" + state
response := browser.get(tenantHost, "/connect/authorize?"+query, nil)
signIn := location(t, response)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, signIn, nil)))
response = browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.memberEmail, "password": j.memberPassword,
"return": returnParam(t, signIn),
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.get(tenantHost, "/connect/authorize?"+query, nil)
if response.StatusCode != http.StatusFound {
t.Fatalf("authorize = %d: %s", response.StatusCode, bodyOf(t, response))
}
callback, err := url.Parse(location(t, response))
if err != nil {
t.Fatalf("parse callback: %v", err)
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
}
exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token",
strings.NewReader(url.Values{
"grant_type": {"authorization_code"}, "code": {callback.Query().Get("code")},
"redirect_uri": {redirectURI},
}.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + basicAuth(j.clientID, j.clientSecret),
})
return decode[tokenResponse](t, exchange, http.StatusOK).AccessToken
}

View file

@ -0,0 +1,127 @@
package behaviour
import (
"net/http"
"net/http/httptest"
"testing"
)
// openAPIDocument is a minimal but real OpenAPI 3.1 description, used both
// pasted into the console and served over HTTP for the scan-a-URL flow.
const openAPIDocument = `{
"openapi": "3.1.0",
"info": {"title": "Guess API", "version": "1.0.0"},
"paths": {
"/health": {
"get": {
"operationId": "health",
"summary": "Liveness probe",
"responses": {"200": {"description": "ok"}}
}
},
"/guesses": {
"get": {
"operationId": "listGuesses",
"summary": "List guesses",
"responses": {"200": {"description": "ok"}}
},
"post": {
"operationId": "createGuess",
"summary": "Create a guess",
"responses": {"201": {"description": "created"}}
}
}
}
}`
// contractPhases imports a service contract from a pasted document and from a
// scanned URL, which is the path an operator takes when registering an
// application.
func contractPhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
phase(t, "a pasted contract is imported with its endpoints", func(t *testing.T) {
type saved struct {
ID string `json:"id"`
Version string `json:"version"`
EndpointCount int `json:"endpoint_count"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{
"application_id": j.applicationID, "source_type": "document", "document": openAPIDocument,
})
contract := decode[saved](t, response, http.StatusCreated)
if contract.Version != "3.1.0" {
t.Errorf("version = %q", contract.Version)
}
if contract.EndpointCount != 3 {
t.Errorf("endpoint_count = %d, want 3", contract.EndpointCount)
}
j.contractID = contract.ID
// The endpoints themselves must be queryable, since the console lists them.
var scanned int
if err := j.h.db.QueryRowContext(t.Context(),
`SELECT count(*) FROM contract_endpoints WHERE contract_id = $1`, contract.ID).Scan(&scanned); err != nil {
t.Fatalf("count contract endpoints: %v", err)
}
if scanned != 3 {
t.Errorf("stored endpoints = %d, want 3", scanned)
}
})
phase(t, "a malformed contract is refused with a readable error", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{
"application_id": j.applicationID, "source_type": "document", "document": `{"openapi":`,
})
body := statusOnly(t, response, http.StatusUnprocessableEntity)
if body == "" {
t.Error("no explanation returned")
}
})
phase(t, "an empty contract is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{
"application_id": j.applicationID, "source_type": "document", "document": "",
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a contract is scanned from a URL", func(t *testing.T) {
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(openAPIDocument))
}))
defer source.Close()
type scanned struct {
ID string `json:"id"`
SourceURL string `json:"source_url"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{
"application_id": j.applicationID, "source_type": "url", "source_url": source.URL + "/openapi.json",
})
result := decode[scanned](t, response, http.StatusCreated)
if result.SourceURL == "" {
t.Error("the source URL was not recorded")
}
})
phase(t, "an unreachable contract URL reports a gateway error", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{
"application_id": j.applicationID, "source_type": "url",
"source_url": "http://127.0.0.1:1/openapi.json",
})
statusOnly(t, response, http.StatusBadGateway)
})
phase(t, "the dashboard reports the stored contracts", func(t *testing.T) {
type listing struct {
Contracts []map[string]any `json:"contracts"`
}
result := decode[listing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK)
if len(result.Contracts) < 2 {
t.Errorf("contracts = %d, want the pasted and scanned documents", len(result.Contracts))
}
})
}

View file

@ -0,0 +1,460 @@
// Package behaviour drives the whole application over real HTTP against a real
// PostgreSQL database, the way a person would: install it, sign in, click
// through every screen, and check that what was written can be read back after
// the process is replaced.
//
// The suite is opt-in. `go test ./...` skips it unless GO_LOOSE_BEHAVIOUR=1 is
// set, because it needs a scratch database and, for the installer, real Google
// credentials. Run it with `make behaviour-test`.
package behaviour
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/server"
)
const (
apexHost = "auth.test"
localAdminMail = "admin@goloose.local"
localAdminPass = "local-admin-password-2026"
sessionSecret = "behaviour-suite-session-secret-value-32"
)
// harness is one running instance of the application plus a browser that speaks
// to it over HTTP.
type harness struct {
t *testing.T
server *http.Server
listener net.Listener
handler http.Handler
db *sql.DB
addr string
baseURL string
origin string
}
// browser is a very small cookie jar with the domain rules that matter here.
// net/http/cookiejar cannot be used because the suite addresses the server by IP
// while sending tenant hostnames in the Host header, and the jar keys cookies by
// the URL host rather than the header. Getting that wrong would silently drop the
// shared session cookie that the client login flow depends on.
type browser struct {
t *testing.T
addr string
client *http.Client
cookies map[string]map[string]*http.Cookie // domain -> name -> cookie
}
// newBrowser returns a cookie jar bound to one instance. Cookies are per browser,
// so a second browser is a second, independent signed-out visitor.
func newBrowser(t *testing.T, addr string) *browser {
return &browser{
t: t, addr: addr,
client: &http.Client{
Timeout: 30 * time.Second,
// Redirects are inspected rather than followed: several flows assert on
// the Location header, and an automatic follow would hide it.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
cookies: map[string]map[string]*http.Cookie{},
}
}
// host extracts the hostname from a Host header that may carry a port.
func hostname(host string) string {
if parsed, _, err := net.SplitHostPort(host); err == nil {
return parsed
}
return host
}
// domainMatches implements the cookie domain rule: an exact host match, or a
// suffix match for a domain cookie.
func domainMatches(domain, host string) bool {
if domain == host {
return true
}
return strings.HasSuffix(host, "."+domain)
}
func (b *browser) absorb(host string, cookies []*http.Cookie) {
for _, cookie := range cookies {
domain := cookie.Domain
if domain == "" {
domain = hostname(host)
}
domain = strings.TrimPrefix(domain, ".")
if cookie.MaxAge < 0 {
if jar, ok := b.cookies[domain]; ok {
delete(jar, cookie.Name)
}
continue
}
if _, ok := b.cookies[domain]; !ok {
b.cookies[domain] = map[string]*http.Cookie{}
}
copied := *cookie
b.cookies[domain][cookie.Name] = &copied
}
}
// request performs one request without following redirects and records cookies.
func (b *browser) request(method, host, path string, body io.Reader, headers map[string]string) *http.Response {
b.t.Helper()
target := "http://" + b.addr + path
request, err := http.NewRequest(method, target, body)
if err != nil {
b.t.Fatalf("build %s %s: %v", method, path, err)
}
request.Host = host
for domain, jar := range b.cookies {
if !domainMatches(domain, hostname(host)) {
continue
}
for name, cookie := range jar {
request.AddCookie(&http.Cookie{Name: name, Value: cookie.Value})
}
}
for name, value := range headers {
if value == "" {
request.Header.Del(name)
continue
}
request.Header.Set(name, value)
}
response, err := b.client.Do(request)
if err != nil {
b.t.Fatalf("%s %s (host %s): %v", method, path, host, err)
}
b.absorb(host, response.Cookies())
return response
}
// get performs a GET and returns the response. The caller closes the body.
func (b *browser) get(host, path string, headers map[string]string) *http.Response {
return b.request(http.MethodGet, host, path, nil, headers)
}
// form performs a form POST, the way a browser submits an HTML form.
func (b *browser) form(host, path string, values url.Values, headers map[string]string) *http.Response {
encoded := values.Encode()
merged := map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "text/html,application/json",
"Content-Length": fmt.Sprint(len(encoded)),
}
for name, value := range headers {
merged[name] = value
}
return b.request(http.MethodPost, host, path, strings.NewReader(encoded), merged)
}
// json performs a JSON write, the way the console's own scripts do. The Origin
// header is what requireSameOrigin checks, so it defaults to the host being used.
func (b *browser) json(method, host, path string, payload any) *http.Response {
encoded, err := json.Marshal(payload)
if err != nil {
b.t.Fatalf("encode payload: %v", err)
}
return b.request(method, host, path, strings.NewReader(string(encoded)), map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Origin": "http://" + host,
})
}
func bodyOf(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()
content, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(content)
}
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
func csrfFrom(t *testing.T, page string) string {
t.Helper()
match := csrfPattern.FindStringSubmatch(page)
if match == nil {
t.Fatalf("no CSRF token in page: %.400s", page)
}
return match[1]
}
// start boots the application on a fresh port. It is called once for the
// install phase and again by restart, which is how the suite proves that state
// lives in PostgreSQL rather than in the process.
// start boots the application. localAdmin is deliberately false for the install
// phase: configuring GO_LOOSE_LOCAL_ADMIN_PASSWORD marks an instance installed on
// its own, which would hide the installer this suite exists to exercise.
func start(t *testing.T, databaseURL string, localAdmin bool) *harness {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
baseURL := "http://" + apexHost + ":" + portOf(addr)
setEnv(t, baseEnv(addr, baseURL, databaseURL, localAdmin))
cfg, err := config.Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
ctx := context.Background()
db, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := database.Migrate(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
handler, err := server.New(ctx, cfg, db, quietLogger())
if err != nil {
t.Fatalf("build server: %v", err)
}
harness := &harness{
t: t, listener: listener, handler: handler.Handler(), db: db,
addr: addr, baseURL: baseURL, origin: baseURL,
}
harness.serve()
return harness
}
func (h *harness) serve() {
h.server = &http.Server{Handler: h.handler, ReadHeaderTimeout: 5 * time.Second}
go func() {
// The listener is already open, so a failure here is a real defect rather
// than a bind race; report it through the test log.
_ = h.server.Serve(h.listener)
}()
h.t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = h.server.Shutdown(ctx)
})
}
// stop closes the running instance. The address is kept so restart can rebind it.
func (h *harness) stop() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = h.server.Shutdown(ctx)
// Shutdown stops accepting but the listener can outlive it briefly, so close it
// explicitly rather than hoping the port is free by the time we rebind.
_ = h.listener.Close()
}
// restart replaces the process with a new one over the same database.
//
// The new instance listens on a fresh port, the way a real deployment would come
// back up behind its proxy. Nothing observable depends on the port: the session
// cookie is scoped to the host without a port, and the application's redirect
// URIs are external.
func (h *harness) restart(databaseURL string) *harness {
h.t.Helper()
h.stop()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
h.t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
baseURL := "http://" + apexHost + ":" + portOf(addr)
setEnv(h.t, baseEnv(addr, baseURL, databaseURL, true))
cfg, err := config.Load()
if err != nil {
h.t.Fatalf("load config: %v", err)
}
ctx := context.Background()
handler, err := server.New(ctx, cfg, h.db, quietLogger())
if err != nil {
h.t.Fatalf("rebuild server: %v", err)
}
next := &harness{
t: h.t, listener: listener, handler: handler.Handler(), db: h.db,
addr: addr, baseURL: baseURL, origin: baseURL,
}
next.serve()
return next
}
// baseEnv is the environment the suite runs the application under.
func baseEnv(addr, baseURL, databaseURL string, localAdmin bool) map[string]string {
env := map[string]string{
"GO_LOOSE_ADDR": addr,
"GO_LOOSE_BASE_URL": baseURL,
"GO_LOOSE_AUTH_DOMAIN": apexHost,
"GO_LOOSE_DATABASE_URL": databaseURL,
"GO_LOOSE_SESSION_SECRET": sessionSecret,
"GO_LOOSE_DEV_LOGIN": "false",
"GO_LOOSE_LOCAL_LOGIN": "true",
"GO_LOOSE_OIDC_REDIRECT_URL": baseURL + "/auth/callback",
"GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS": "true",
"GO_LOOSE_BOOTSTRAP_TENANT": "nmbs",
"GO_LOOSE_BOOTSTRAP_APP": "guess",
// The provider credentials are never injected through the environment: the
// installer must be the only thing that configures OIDC here.
"GO_LOOSE_OIDC_ISSUER": "",
"GO_LOOSE_OIDC_CLIENT_ID": "",
"GO_LOOSE_OIDC_CLIENT_SECRET": "",
}
if localAdmin {
env["GO_LOOSE_LOCAL_ADMIN_EMAIL"] = localAdminMail
env["GO_LOOSE_LOCAL_ADMIN_PASSWORD"] = localAdminPass
}
return env
}
func portOf(addr string) string {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return "8080"
}
return port
}
// tenantHost is the hostname a tenant's members sign in on.
func (h *harness) tenantHost(slug string) string {
return slug + "." + apexHost + ":" + portOf(h.addr)
}
func (h *harness) apex() string {
return apexHost + ":" + portOf(h.addr)
}
func setEnv(t *testing.T, values map[string]string) {
t.Helper()
for name, value := range values {
t.Setenv(name, value)
}
}
// resetDatabase drops every table including schema_migrations, so the next start
// replays the migration history from the beginning.
func resetDatabase(t *testing.T, databaseURL string) {
t.Helper()
db, err := database.Open(context.Background(), databaseURL)
if err != nil {
t.Fatalf("open database to reset: %v", err)
}
defer db.Close()
rows, err := db.QueryContext(context.Background(),
`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
t.Fatalf("list tables: %v", err)
}
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
t.Fatalf("scan table name: %v", err)
}
tables = append(tables, `"`+name+`"`)
}
rows.Close()
for _, table := range tables {
if _, err := db.ExecContext(context.Background(), "DROP TABLE IF EXISTS "+table+" CASCADE"); err != nil {
t.Fatalf("drop %s: %v", table, err)
}
}
}
// googleCredentials reads the real client credentials the installer is meant to
// receive. The values never appear in test output.
type googleCredentials struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
func loadGoogleCredentials(t *testing.T) googleCredentials {
t.Helper()
if override := os.Getenv("GO_LOOSE_BEHAVIOUR_CLIENT_ID"); override != "" {
return googleCredentials{ClientID: override, ClientSecret: os.Getenv("GO_LOOSE_BEHAVIOUR_CLIENT_SECRET")}
}
path := filepath.Join("..", "..", "google_secrets.json")
content, err := os.ReadFile(path)
if err != nil {
t.Skipf("no Google credentials at %s: %v", path, err)
}
var file struct {
Web struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
} `json:"web"`
}
if err := json.Unmarshal(content, &file); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
if file.Web.ClientID == "" || file.Web.ClientSecret == "" {
t.Skipf("%s has no client_id or client_secret", path)
}
return googleCredentials{ClientID: file.Web.ClientID, ClientSecret: file.Web.ClientSecret}
}
// decode reads a JSON response body into target and fails on a non-2xx status,
// reporting the server's own error message.
func decode[T any](t *testing.T, response *http.Response, wantStatus int) T {
t.Helper()
var target T
body := bodyOf(t, response)
if response.StatusCode != wantStatus {
t.Fatalf("status = %d, want %d: %s", response.StatusCode, wantStatus, body)
}
if err := json.Unmarshal([]byte(body), &target); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
return target
}
// statusOnly reads a body without decoding, for flows whose payload is empty.
func statusOnly(t *testing.T, response *http.Response, wantStatus int) string {
t.Helper()
body := bodyOf(t, response)
if response.StatusCode != wantStatus {
t.Fatalf("status = %d, want %d: %s", response.StatusCode, wantStatus, body)
}
return body
}
// location returns the redirect target of a response.
func location(t *testing.T, response *http.Response) string {
t.Helper()
target := response.Header.Get("Location")
if target == "" {
t.Fatalf("no Location header on %d", response.StatusCode)
}
return target
}
// quietLogger keeps the suite's output readable. The application logs every
// request, which would bury the test output; the suite reports its own failures.
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// stringReader is a small helper for the raw request bodies the authorization
// endpoint expects, where the exact bytes matter more than a struct.
func stringReader(body string) io.Reader { return strings.NewReader(body) }

View file

@ -0,0 +1,466 @@
package behaviour
import (
"database/sql"
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"testing"
)
// journey carries the identifiers one phase creates into the phases that use
// them, so each phase reads like a step a person would take.
type journey struct {
t *testing.T
h *harness
admin *browser
adminEmail string
tenantID string
tenantSlug string
scopedTenant string
scopedTenantID string
applicationID string
clientID string
clientSecret string
apiKey string
apiKeyID string
contractID string
customRoleID string
permission string
memberEmail string
memberPassword string
ungrantedEmail string
ungrantedPassword string
ungrantedMemberID string
memberID string
userRoleID string
viewerRoleID string
avatarKey string
accessToken string
profileName string
}
// browser returns a fresh, signed-out visitor pointed at the current instance.
func (j *journey) browser() *browser { return newBrowser(j.t, j.h.addr) }
// restart replaces the running process and points the signed-in browser at the new
// one. The cookie jar is kept deliberately: it is the same person, and their
// session cookie is scoped to the host rather than to the port.
func (j *journey) restart(databaseURL string) {
j.h = j.h.restart(databaseURL)
if j.admin != nil {
j.admin.addr = j.h.addr
}
}
// adminHost is the tenant hostname the local administrator belongs to.
func (j *journey) adminHost() string { return j.h.tenantHost(j.tenantSlug) }
// phase runs one step. Steps run in order and share state, which is why this is
// a single test with subtests rather than a set of independent ones.
func phase(t *testing.T, name string, step func(t *testing.T)) {
t.Helper()
t.Run(name, step)
}
func requireScratchDatabase(t *testing.T, databaseURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_BEHAVIOUR_DESTRUCTIVE") == "1" {
return
}
parsed, err := url.Parse(databaseURL)
if err != nil {
t.Fatalf("parse database URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run against database %q: the suite drops every table. "+
"Point GO_LOOSE_BEHAVIOUR_DATABASE_URL at a scratch database whose name contains \"test\".", name)
}
}
// TestApplicationBehaviour walks the entire application once, in the order a
// person would: install it, sign in, build out a tenant, wire an application,
// manage people and roles, edit a profile, then replace the process and check
// that everything is still there.
func TestApplicationBehaviour(t *testing.T) {
if os.Getenv("GO_LOOSE_BEHAVIOUR") != "1" {
t.Skip("set GO_LOOSE_BEHAVIOUR=1 and GO_LOOSE_BEHAVIOUR_DATABASE_URL to run the behaviour suite")
}
databaseURL := os.Getenv("GO_LOOSE_BEHAVIOUR_DATABASE_URL")
if databaseURL == "" {
t.Skip("set GO_LOOSE_BEHAVIOUR_DATABASE_URL to a scratch database")
}
requireScratchDatabase(t, databaseURL)
// Start from nothing: the suite is meant to prove a first install works.
resetDatabase(t, databaseURL)
credentials := loadGoogleCredentials(t)
j := &journey{t: t, h: start(t, databaseURL, false), adminEmail: localAdminMail}
j.admin = j.browser()
installPhases(t, j, credentials)
// The operator now adds the local administrator and restarts, which is also the
// first proof that the installation survived the process it was written by.
j.restart(databaseURL)
authenticationPhases(t, j)
consolePhases(t, j)
tenantPhases(t, j)
applicationPhases(t, j)
contractPhases(t, j)
membershipPhases(t, j)
clientLoginPhases(t, j)
rolePhases(t, j)
profilePhases(t, j)
securityPhases(t, j)
persistencePhases(t, j)
}
// installPhases drives the first-run installer with the real Google credentials
// and then checks what reached PostgreSQL.
func installPhases(t *testing.T, j *journey, credentials googleCredentials) {
t.Helper()
phase(t, "an uninstalled instance sends the console to the installer", func(t *testing.T) {
response := j.browser().get(j.h.apex(), "/", nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", response.StatusCode)
}
if got := location(t, response); got != "/install" {
t.Fatalf("redirect = %q, want /install", got)
}
})
phase(t, "the installer refuses a submission without its CSRF token", func(t *testing.T) {
browser := j.browser()
page := bodyOf(t, browser.get(j.h.apex(), "/install", nil))
token := csrfFrom(t, page)
// The cookie was issued for the page, so the mismatch below is the token,
// not a missing cookie.
browser.cookies = map[string]map[string]*http.Cookie{}
response := browser.form(j.h.apex(), "/install", url.Values{
"csrf_token": {token}, "field1": {"https://accounts.google.com"},
"field2": {credentials.ClientID}, "field3": {credentials.ClientSecret},
"field4": {j.h.baseURL + "/auth/callback"},
}, nil)
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "the installer refuses an incomplete submission", func(t *testing.T) {
browser := j.browser()
token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/install", nil)))
response := browser.form(j.h.apex(), "/install", url.Values{
"csrf_token": {token}, "field1": {"https://accounts.google.com"},
}, nil)
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "installing stores the provider settings and seeds the demo data", func(t *testing.T) {
browser := j.browser()
token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/install", nil)))
response := browser.form(j.h.apex(), "/install", url.Values{
"csrf_token": {token},
"field1": {"https://accounts.google.com"},
"field2": {credentials.ClientID},
"field3": {credentials.ClientSecret},
"field4": {j.h.baseURL + "/auth/callback"},
"seed_demo": {"on"},
}, nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response))
}
if got := location(t, response); got != "/auth/start" {
t.Fatalf("redirect = %q, want /auth/start", got)
}
var issuer, clientID, ciphertext, redirect string
var seeded bool
row := j.h.db.QueryRowContext(t.Context(), `
SELECT oidc_issuer, oidc_client_id, encode(oidc_client_secret_ciphertext, 'escape'), oidc_redirect_url, seeded_demo
FROM installation_settings WHERE singleton`)
if err := row.Scan(&issuer, &clientID, &ciphertext, &redirect, &seeded); err != nil {
t.Fatalf("read installation_settings: %v", err)
}
if issuer != "https://accounts.google.com" {
t.Errorf("issuer = %q", issuer)
}
if clientID != credentials.ClientID {
t.Errorf("client id = %q, want the credentials from google_secrets.json", clientID)
}
if redirect != j.h.baseURL+"/auth/callback" {
t.Errorf("redirect url = %q", redirect)
}
if !seeded {
t.Error("seeded_demo = false, want true")
}
// The secret must be encrypted at rest, never the plaintext from the form.
if strings.Contains(ciphertext, credentials.ClientSecret) {
t.Error("the client secret is stored in plaintext")
}
if ciphertext == "" {
t.Error("the client secret was not stored")
}
for _, want := range []string{"nmbs", "ypto"} {
var slug string
if err := j.h.db.QueryRowContext(t.Context(),
`SELECT slug FROM tenants WHERE slug = $1`, want).Scan(&slug); err != nil {
t.Errorf("tenant %s was not seeded: %v", want, err)
}
}
var demoUsers int
if err := j.h.db.QueryRowContext(t.Context(),
`SELECT count(*) FROM users WHERE email LIKE '%@nmbs.auth.dev' OR email LIKE '%@ypto.auth.dev'`).
Scan(&demoUsers); err != nil {
t.Fatalf("count demo users: %v", err)
}
if demoUsers != 4 {
t.Errorf("demo users = %d, want 4", demoUsers)
}
})
phase(t, "an installed instance no longer offers the installer", func(t *testing.T) {
browser := j.browser()
response := browser.get(j.h.apex(), "/install", nil)
if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" {
t.Fatalf("GET /install = %d %q, want 303 to /login", response.StatusCode, response.Header.Get("Location"))
}
token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/login", nil)))
response = browser.form(j.h.apex(), "/install", url.Values{
"csrf_token": {token}, "field1": {"https://accounts.google.com"},
"field2": {credentials.ClientID}, "field3": {credentials.ClientSecret},
"field4": {j.h.baseURL + "/auth/callback"},
}, nil)
statusOnly(t, response, http.StatusConflict)
})
phase(t, "sign-in redirects to Google with the installed client id", func(t *testing.T) {
browser := j.browser()
response := browser.get(j.h.apex(), "/auth/start", nil)
if response.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want 302: %s", response.StatusCode, bodyOf(t, response))
}
provider, err := url.Parse(location(t, response))
if err != nil {
t.Fatalf("parse provider URL: %v", err)
}
if provider.Host != "accounts.google.com" {
t.Errorf("provider host = %q, want accounts.google.com", provider.Host)
}
query := provider.Query()
if got := query.Get("client_id"); got != credentials.ClientID {
t.Errorf("client_id = %q, want the installed credentials", got)
}
if got := query.Get("redirect_uri"); got != j.h.baseURL+"/auth/callback" {
t.Errorf("redirect_uri = %q", got)
}
if query.Get("response_type") != "code" {
t.Errorf("response_type = %q, want code", query.Get("response_type"))
}
// The signed state must be echoed back to the callback.
if query.Get("state") == "" {
t.Error("no state parameter in the provider redirect")
}
})
phase(t, "the callback refuses a forged state", func(t *testing.T) {
browser := j.browser()
browser.get(j.h.apex(), "/auth/start", nil)
response := browser.get(j.h.apex(), "/auth/callback?code=fake&state=forged", nil)
statusOnly(t, response, http.StatusBadRequest)
})
}
// authenticationPhases signs in as the bootstrapped local administrator and checks
// that the session is a real one.
func authenticationPhases(t *testing.T, j *journey) {
t.Helper()
j.tenantSlug = "nmbs"
phase(t, "the sign-in page is served on the tenant host", func(t *testing.T) {
host := j.adminHost()
page := bodyOf(t, j.browser().get(host, "/login", nil))
if !strings.Contains(page, "NMBS") {
t.Errorf("the sign-in page does not mention the tenant: %.200s", page)
}
})
phase(t, "a wrong password is rejected", func(t *testing.T) {
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", url.Values{
"csrf_token": {token}, "email": {localAdminMail}, "password": {"wrong-password-entirely"},
}, nil)
statusOnly(t, response, http.StatusUnauthorized)
})
phase(t, "password sign-in without a CSRF token is rejected", func(t *testing.T) {
browser := j.browser()
host := j.adminHost()
bodyOf(t, browser.get(host, "/login", nil))
response := browser.form(host, "/auth/password", url.Values{
"email": {localAdminMail}, "password": {localAdminPass},
}, nil)
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "repeated failures lock the account out", func(t *testing.T) {
// A distinct address keeps the lockout away from the other sign-ins.
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
locked := false
for attempt := 0; attempt < 6; attempt++ {
response := browser.form(host, "/auth/password", url.Values{
"csrf_token": {token}, "email": {"lockout@auth.test"}, "password": {"nope-nope-nope"},
}, nil)
response.Body.Close()
if response.StatusCode == http.StatusTooManyRequests {
locked = true
break
}
}
if !locked {
t.Error("six wrong passwords never produced a 429")
}
})
phase(t, "the administrator signs in and reaches the console", func(t *testing.T) {
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", url.Values{
"csrf_token": {token}, "email": {localAdminMail}, "password": {localAdminPass},
}, nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response))
}
if got := location(t, response); got != "/" {
t.Fatalf("redirect = %q, want /", got)
}
j.admin = browser
page := bodyOf(t, j.admin.get(j.adminHost(), "/", nil))
if !strings.Contains(page, "<html") {
t.Error("the console page is not HTML")
}
})
phase(t, "the management session does not leak to the apex host", func(t *testing.T) {
// The session cookie is issued without a Domain, so it belongs to the host
// that signed in. That is deliberate: a tenant session must not be usable on
// the installation-wide host.
response := j.admin.get(j.h.apex(), "/api/v1/dashboard", nil)
statusOnly(t, response, http.StatusUnauthorized)
})
phase(t, "the session is refused without its cookie", func(t *testing.T) {
stranger := j.browser()
response := stranger.get(j.adminHost(), "/api/v1/dashboard", nil)
statusOnly(t, response, http.StatusUnauthorized)
})
}
// consolePhases checks that every console screen and its data load.
func consolePhases(t *testing.T, j *journey) {
t.Helper()
phase(t, "the dashboard reports the installed state", func(t *testing.T) {
type dashboard struct {
Tenants []map[string]any `json:"tenants"`
Applications []map[string]any `json:"applications"`
Keys []map[string]any `json:"keys"`
Contracts []map[string]any `json:"contracts"`
Users []map[string]any `json:"users"`
PendingUsers []map[string]any `json:"pending_users"`
Permissions []map[string]any `json:"permissions"`
Profile map[string]any `json:"profile"`
SystemAdministrator bool `json:"is_system_administrator"`
}
response := j.admin.get(j.adminHost(), "/api/v1/dashboard", nil)
data := decode[dashboard](t, response, http.StatusOK)
if len(data.Tenants) == 0 {
t.Error("the dashboard lists no tenants")
}
if len(data.Permissions) == 0 {
t.Error("the dashboard lists no permissions")
}
if data.Profile["email"] != localAdminMail {
t.Errorf("profile email = %v, want %s", data.Profile["email"], localAdminMail)
}
if !data.SystemAdministrator {
t.Error("the bootstrapped administrator is not reported as a system administrator")
}
// Find the tenant this journey works in.
for _, tenant := range data.Tenants {
if tenant["slug"] == j.tenantSlug {
j.tenantID, _ = tenant["id"].(string)
}
}
if j.tenantID == "" {
t.Fatalf("tenant %s is missing from the dashboard: %v", j.tenantSlug, data.Tenants)
}
})
phase(t, "every console screen is served", func(t *testing.T) {
for _, path := range []string{"/", "/profile", "/roles", "/docs", "/openapi.json"} {
page := bodyOf(t, j.admin.get(j.adminHost(), path, nil))
if !strings.Contains(page, "<html") && path != "/openapi.json" {
t.Errorf("%s did not return a page", path)
}
}
})
phase(t, "every script and stylesheet is served and nothing else is", func(t *testing.T) {
for _, asset := range []string{"style.css", "app.js", "profile.js", "roles.js", "account.js"} {
response := j.admin.get(j.adminHost(), "/assets/"+asset, nil)
if response.StatusCode != http.StatusOK {
t.Errorf("GET /assets/%s = %d", asset, response.StatusCode)
}
response.Body.Close()
}
for _, asset := range []string{"secret.js", "style.css.map"} {
response := j.admin.get(j.adminHost(), "/assets/"+asset, nil)
if response.StatusCode != http.StatusNotFound {
t.Errorf("GET /assets/%s = %d, want 404", asset, response.StatusCode)
}
response.Body.Close()
}
})
phase(t, "the published specification is valid JSON", func(t *testing.T) {
var spec map[string]any
if err := json.Unmarshal([]byte(bodyOf(t, j.admin.get(j.adminHost(), "/openapi.json", nil))), &spec); err != nil {
t.Fatalf("parse openapi.json: %v", err)
}
if _, ok := spec["paths"]; !ok {
t.Error("the specification has no paths")
}
})
}
// pngBase64 is a tiny valid PNG used for the profile picture flows, and
// decodeBase64PNG returns the same bytes for checks that need the raw image.
const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="
func decodeBase64PNG(t *testing.T) []byte {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(pngBase64)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
return raw
}
var _ = sql.ErrNoRows
// The remaining phases are defined in their own files so each one stays
// readable: tenant, application, contract, client login, role, membership,
// profile, security, and persistence.

View file

@ -0,0 +1,171 @@
package behaviour
import (
"net/http"
"testing"
)
// membershipPhases creates the person the hosted-login flow signs in as, and gives
// them exactly the two things a tenant application needs: membership of the tenant
// and access to its application.
func membershipPhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
j.memberEmail = "member@behaviour.test"
j.memberPassword = "member-password-2026"
phase(t, "a role is chosen from the tenant's catalogue", func(t *testing.T) {
type role struct {
ID string `json:"id"`
Slug string `json:"slug"`
System bool `json:"system"`
Permissions []string `json:"permissions"`
Assignable bool `json:"assignable"`
}
type listing struct {
Roles []role `json:"roles"`
}
response := j.admin.get(host, "/api/v1/roles?tenant_id="+j.scopedTenantID, nil)
roles := decode[listing](t, response, http.StatusOK)
if len(roles.Roles) < 6 {
t.Fatalf("roles = %d, want at least the six built-ins", len(roles.Roles))
}
seen := map[string]bool{}
for _, item := range roles.Roles {
seen[item.Slug] = true
if item.Slug == "user" {
j.userRoleID = item.ID
}
if item.Slug == "viewer" {
j.viewerRoleID = item.ID
}
}
for _, want := range []string{"owner", "admin", "developer", "operator", "viewer", "user"} {
if !seen[want] {
t.Errorf("built-in role %q is missing", want)
}
}
if j.userRoleID == "" || j.viewerRoleID == "" {
t.Fatal("could not resolve the built-in roles")
}
})
phase(t, "an email address that is not a plain address is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": "Real Name <member@behaviour.test>",
"role_id": j.viewerRoleID, "password": j.memberPassword,
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a short password is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": "weak@behaviour.test",
"role_id": j.viewerRoleID, "password": "short",
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a member is invited and given access to the application", func(t *testing.T) {
type invited struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Status string `json:"status"`
Permissions []string `json:"permissions"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": j.memberEmail,
"display_name": "Behaviour Member", "role_id": j.viewerRoleID,
"password": j.memberPassword,
})
member := decode[invited](t, response, http.StatusCreated)
if member.Email != j.memberEmail {
t.Errorf("email = %q", member.Email)
}
if member.Status != "invited" {
// An invitation is not a login: the account stays "invited" until the
// person signs in for the first time.
t.Errorf("status = %q, want invited", member.Status)
}
if member.Role != "viewer" {
t.Errorf("role = %q, want viewer", member.Role)
}
j.memberID = member.ID
// Membership alone is not enough: the client login flow also needs an
// application grant.
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+member.ID+"/access", map[string]any{
"tenant_id": j.scopedTenantID, "role_id": j.viewerRoleID,
"application_ids": []string{j.applicationID},
})
if access.StatusCode != http.StatusNoContent {
t.Fatalf("grant access = %d: %s", access.StatusCode, bodyOf(t, access))
}
var granted int
if err := j.h.db.QueryRowContext(t.Context(), `
SELECT count(*) FROM user_application_access
WHERE user_id = $1 AND application_id = $2`, member.ID, j.applicationID).Scan(&granted); err != nil {
t.Fatalf("count grants: %v", err)
}
if granted != 1 {
t.Errorf("application grants = %d, want 1", granted)
}
})
phase(t, "a second member is invited without any application grant", func(t *testing.T) {
// Membership and application access are separate decisions, and the client
// login flow depends on the difference.
j.ungrantedEmail = "ungranted@behaviour.test"
j.ungrantedPassword = "ungranted-password-2026"
response := j.admin.json(http.MethodPost, host, "/api/v1/users", map[string]any{
"tenant_id": j.scopedTenantID, "email": j.ungrantedEmail,
"display_name": "Ungranted Member", "role_id": j.userRoleID,
"password": j.ungrantedPassword,
})
if response.StatusCode != http.StatusCreated {
t.Fatalf("invite = %d: %s", response.StatusCode, bodyOf(t, response))
}
var grants int
if err := j.h.db.QueryRowContext(t.Context(), `
SELECT count(*) FROM user_application_access WHERE user_id = (
SELECT id FROM users WHERE email = $1)`, j.ungrantedEmail).Scan(&grants); err != nil {
t.Fatalf("count grants: %v", err)
}
if grants != 0 {
t.Errorf("grants = %d, want none", grants)
}
if err := j.h.db.QueryRowContext(t.Context(),
`SELECT id FROM users WHERE email = $1`, j.ungrantedEmail).Scan(&j.ungrantedMemberID); err != nil {
t.Fatalf("look up the ungranted member: %v", err)
}
})
phase(t, "the member appears in the tenant's user list", func(t *testing.T) {
type listing struct {
Users []struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
ApplicationIDs []string `json:"application_ids"`
} `json:"users"`
}
result := decode[listing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK)
found := false
for _, user := range result.Users {
if user.Email == j.memberEmail {
found = true
if len(user.ApplicationIDs) != 1 {
t.Errorf("application grants reported = %v", user.ApplicationIDs)
}
}
}
if !found {
t.Errorf("%s is missing from the dashboard", j.memberEmail)
}
})
}

View file

@ -0,0 +1,248 @@
package behaviour
import (
"net/http"
"os"
"strings"
"testing"
)
// persistencePhases replaces the running process and then reads everything back.
// This is the phase the whole suite exists for: it proves the application keeps
// its state in PostgreSQL rather than in memory, which is the one property that
// cannot be checked by looking at a single request.
func persistencePhases(t *testing.T, j *journey) {
t.Helper()
// Take a copy of the identifiers so the assertions below can be made against
// values captured before the restart rather than against whatever the new
// process happens to hand back.
want := struct {
tenant string
application string
contract string
apiKey string
clientID string
member string
customRole string
displayName string
avatarSet bool
memberGrants int
}{
tenant: j.scopedTenant, application: j.applicationID, contract: j.contractID,
apiKey: j.apiKeyID, clientID: j.clientID, member: j.memberEmail,
customRole: j.customRoleID, displayName: j.profileName,
}
// Put a picture back so the restart has binary state to preserve.
response := j.admin.json(http.MethodPut, j.adminHost(), "/api/v1/profile", map[string]any{
"avatar": pngBase64,
})
if response.StatusCode != http.StatusOK {
t.Fatalf("set a picture to carry across the restart: %d", response.StatusCode)
}
type withAvatar struct {
AvatarURL string `json:"avatar_url"`
}
avatarPath := decode[withAvatar](t, response, http.StatusOK).AvatarURL
want.avatarSet = avatarPath != ""
phase(t, "a client session is established before the restart", func(t *testing.T) {
// The earlier phase revoked its token on purpose, so a fresh one is needed to
// show that a session outlives the process.
j.accessToken = establishClientSession(t, j, "before-restart")
if j.accessToken == "" {
t.Fatal("no client session token")
}
response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
statusOnly(t, response, http.StatusOK)
})
phase(t, "the installation survives the process that wrote it", func(t *testing.T) {
j.restart(os.Getenv("GO_LOOSE_BEHAVIOUR_DATABASE_URL"))
// A restarted instance must know it is installed without the environment
// saying so, which means it read the installation row and decrypted the
// stored OIDC secret.
response := j.browser().get(j.h.apex(), "/install", nil)
if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" {
t.Errorf("GET /install after restart = %d %q, want 303 to /login",
response.StatusCode, response.Header.Get("Location"))
}
})
phase(t, "the provider configuration still works after the restart", func(t *testing.T) {
browser := j.browser()
response := browser.get(j.h.apex(), "/auth/start", nil)
if response.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want a provider redirect: %s", response.StatusCode, bodyOf(t, response))
}
target := location(t, response)
if !strings.Contains(target, "accounts.google.com") {
t.Errorf("redirect = %q, want the installed provider", target)
}
if !strings.Contains(target, "client_id=") {
t.Errorf("the provider redirect carries no client id: %s", target)
}
})
phase(t, "an existing session is still valid after the restart", func(t *testing.T) {
// The session is a signed cookie, so a new process with the same secret
// keeps honouring it.
response := j.admin.get(j.adminHost(), "/api/v1/dashboard", nil)
statusOnly(t, response, http.StatusOK)
})
phase(t, "the tenant, application, contract, and revoked key all persist", func(t *testing.T) {
var tenants, applications, contracts, endpoints int
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM tenants WHERE slug = $1`, want.tenant).Scan(&tenants); err != nil {
t.Fatalf("count tenants: %v", err)
}
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM applications WHERE id = $1`, want.application).Scan(&applications); err != nil {
t.Fatalf("count applications: %v", err)
}
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM openapi_contracts WHERE id = $1`, want.contract).Scan(&contracts); err != nil {
t.Fatalf("count contracts: %v", err)
}
if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM contract_endpoints WHERE contract_id = $1`, want.contract).Scan(&endpoints); err != nil {
t.Fatalf("count endpoints: %v", err)
}
if tenants != 1 || applications != 1 || contracts != 1 || endpoints != 3 {
t.Errorf("persisted: tenants=%d applications=%d contracts=%d endpoints=%d",
tenants, applications, contracts, endpoints)
}
// The revoked key must stay revoked.
var status string
if err := j.h.db.QueryRowContext(t.Context(), `SELECT status FROM api_keys WHERE id = $1`, want.apiKey).Scan(&status); err != nil {
t.Fatalf("read the key: %v", err)
}
if status != "revoked" {
t.Errorf("key status = %q, want revoked", status)
}
})
phase(t, "the dashboard reports the same state to the new process", func(t *testing.T) {
type listing struct {
Tenants []struct{ Slug string } `json:"tenants"`
Applications []struct {
Slug string `json:"slug"`
RedirectURIs []string `json:"redirect_uris"`
} `json:"applications"`
Users []struct {
Email string `json:"email"`
Role string `json:"role"`
} `json:"users"`
}
result := decode[listing](t, j.admin.get(j.adminHost(), "/api/v1/dashboard", nil), http.StatusOK)
slugs := map[string]bool{}
for _, tenant := range result.Tenants {
slugs[tenant.Slug] = true
}
if !slugs[want.tenant] {
t.Errorf("tenant %s is missing after the restart", want.tenant)
}
emails := map[string]string{}
for _, user := range result.Users {
emails[user.Email] = user.Role
}
if emails[want.member] == "" {
t.Errorf("%s is missing after the restart", want.member)
}
if emails[localAdminMail] != "owner" {
t.Errorf("the administrator's role is %q after the restart, want owner", emails[localAdminMail])
}
})
phase(t, "the customized display name and the picture both persist", func(t *testing.T) {
type profile struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
NameCustomized bool `json:"name_customized"`
}
result := decode[profile](t, j.admin.get(j.adminHost(), "/api/v1/profile", nil), http.StatusOK)
if result.DisplayName != want.displayName {
t.Errorf("display name = %q, want %q", result.DisplayName, want.displayName)
}
if !result.NameCustomized {
t.Error("name_customized was lost")
}
if result.AvatarURL != avatarPath {
t.Errorf("avatar_url = %q, want %q", result.AvatarURL, avatarPath)
}
if !want.avatarSet {
t.Fatal("no picture was set before the restart")
}
})
phase(t, "the picture is still served, with the same bytes", func(t *testing.T) {
key := strings.TrimPrefix(avatarPath, "/api/v1/avatars/")
response := j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want the picture to survive: %s", response.StatusCode, bodyOf(t, response))
}
if got := response.Header.Get("Content-Type"); got != "image/png" {
t.Errorf("content type = %q", got)
}
if len(bodyOf(t, response)) == 0 {
t.Error("empty picture after the restart")
}
})
phase(t, "the tenant role and the permission catalog persist", func(t *testing.T) {
type listing struct {
Roles []struct {
Slug string `json:"slug"`
} `json:"roles"`
}
result := decode[listing](t, j.admin.get(j.adminHost(),
"/api/v1/roles?tenant_id="+j.scopedTenantID, nil), http.StatusOK)
found := false
for _, role := range result.Roles {
if role.Slug == "reporter" {
found = true
}
}
if !found {
t.Error("the tenant-defined role is missing after the restart")
}
statusOnly(t, j.admin.get(j.adminHost(), "/api/v1/permissions", nil), http.StatusOK)
})
phase(t, "a client session token issued before the restart still resolves", func(t *testing.T) {
// The session is a row in PostgreSQL, not process state, so a deployment
// that restarts does not sign every user out of the tenant applications.
if j.accessToken == "" {
t.Fatal("no client session was established before the restart")
}
type identity struct {
Email string `json:"email"`
}
response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil,
map[string]string{"Authorization": "Bearer " + j.accessToken})
user := decode[identity](t, response, http.StatusOK)
if user.Email != j.memberEmail {
t.Errorf("email = %q, want %s", user.Email, j.memberEmail)
}
})
phase(t, "a new sign-in still works after the restart", func(t *testing.T) {
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": localAdminMail, "password": localAdminPass,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in after restart = %d: %s", response.StatusCode, bodyOf(t, response))
}
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusOK)
})
phase(t, "an API key issued before the restart is still refused after it", func(t *testing.T) {
response := j.browser().request(http.MethodPost, j.h.apex(), "/api/v1/authorize",
stringReader(`{"tenant":"behaviour","application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`),
map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"})
statusOnly(t, response, http.StatusForbidden)
})
}

View file

@ -0,0 +1,287 @@
package behaviour
import (
"encoding/base64"
"net/http"
"strings"
"testing"
)
// profilePhases edits the caller's own display name and picture, and checks the
// public URL that client applications such as go-tell embed.
func profilePhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
phase(t, "the profile reports the caller and their memberships", func(t *testing.T) {
type membership struct {
TenantSlug string `json:"tenant_slug"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Permissions []string `json:"permissions"`
}
type profile struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Memberships []membership `json:"memberships"`
NameCustomized bool `json:"name_customized"`
}
result := decode[profile](t, j.admin.get(host, "/api/v1/profile", nil), http.StatusOK)
if result.Email != localAdminMail {
t.Errorf("email = %q", result.Email)
}
if result.DisplayName != "Local Administrator" {
t.Errorf("display name = %q", result.DisplayName)
}
if result.AvatarURL != "" {
t.Errorf("avatar_url = %q, want empty before a picture is set", result.AvatarURL)
}
if result.NameCustomized {
t.Error("name_customized is true before the name was edited")
}
if len(result.Memberships) == 0 {
t.Fatal("no memberships reported")
}
for _, membership := range result.Memberships {
if len(membership.Permissions) == 0 {
t.Errorf("membership %s reports no permissions", membership.TenantSlug)
}
}
})
phase(t, "an empty update is refused", func(t *testing.T) {
statusOnly(t, j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{}), http.StatusBadRequest)
})
phase(t, "an empty display name is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{"display_name": " "})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "the display name is changed and marked as customized", func(t *testing.T) {
j.profileName = "Ada Lovelace"
type updated struct {
DisplayName string `json:"display_name"`
NameCustomized bool `json:"name_customized"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"display_name": j.profileName,
})
result := decode[updated](t, response, http.StatusOK)
if result.DisplayName != j.profileName {
t.Errorf("display name = %q", result.DisplayName)
}
if !result.NameCustomized {
t.Error("name_customized was not set")
}
})
phase(t, "the customized name survives signing in again", func(t *testing.T) {
// Signing in upserts the user from the identity provider, which would
// otherwise overwrite the name the person chose.
browser := j.browser()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": localAdminMail, "password": localAdminPass,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
type profile struct {
DisplayName string `json:"display_name"`
}
result := decode[profile](t, browser.get(host, "/api/v1/profile", nil), http.StatusOK)
if result.DisplayName != j.profileName {
t.Errorf("display name = %q after signing in, want %q", result.DisplayName, j.profileName)
}
})
phase(t, "a picture is rejected when it is not an image", func(t *testing.T) {
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": base64.StdEncoding.EncodeToString([]byte("this is not an image")),
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "an unsupported image type is rejected", func(t *testing.T) {
// A real PNG announced as a PDF: the declared type must be checked.
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(decodeBase64PNG(t)),
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a picture is accepted and published under a key", func(t *testing.T) {
type updated struct {
AvatarURL string `json:"avatar_url"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": base64.StdEncoding.EncodeToString(decodeBase64PNG(t)),
})
result := decode[updated](t, response, http.StatusOK)
if !strings.HasPrefix(result.AvatarURL, "/api/v1/avatars/") {
t.Fatalf("avatar_url = %q, want a path under /api/v1/avatars", result.AvatarURL)
}
j.avatarKey = strings.TrimPrefix(result.AvatarURL, "/api/v1/avatars/")
if j.avatarKey == "" {
t.Fatal("no avatar key")
}
})
phase(t, "the picture is served to anyone, without a session", func(t *testing.T) {
stranger := j.browser()
response := stranger.get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", response.StatusCode)
}
if got := response.Header.Get("Content-Type"); got != "image/png" {
t.Errorf("content type = %q, want image/png", got)
}
if got := response.Header.Get("Cache-Control"); !strings.Contains(got, "max-age") {
t.Errorf("cache control = %q", got)
}
if response.Header.Get("ETag") == "" {
t.Error("no ETag on a picture")
}
if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("nosniff = %q", got)
}
body := bodyOf(t, response)
if len(body) == 0 {
t.Error("empty picture body")
}
})
phase(t, "an unchanged picture answers 304", func(t *testing.T) {
first := j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil)
etag := first.Header.Get("ETag")
bodyOf(t, first)
second := j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, map[string]string{
"If-None-Match": etag,
})
if second.StatusCode != http.StatusNotModified {
t.Fatalf("status = %d, want 304", second.StatusCode)
}
second.Body.Close()
})
phase(t, "an unknown or malformed picture key never serves anything", func(t *testing.T) {
for _, key := range []string{"does-not-exist", strings.Repeat("a", 43), "..%2f..%2fetc%2fpasswd"} {
response := j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil)
// A traversal may be normalised into a redirect by the router; what matters
// is that no bytes are ever served for it.
if response.StatusCode == http.StatusOK {
t.Errorf("key %q returned a picture", key)
}
bodyOf(t, response)
}
})
phase(t, "replacing the picture retires the old key", func(t *testing.T) {
original := j.avatarKey
type updated struct {
AvatarURL string `json:"avatar_url"`
}
// A different image: the 1x1 PNG's colour channel is inverted.
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{
"avatar": base64.StdEncoding.EncodeToString(decodeBase64PNG(t)),
})
result := decode[updated](t, response, http.StatusOK)
j.avatarKey = strings.TrimPrefix(result.AvatarURL, "/api/v1/avatars/")
if j.avatarKey == "" {
t.Fatal("no avatar key after replacing the picture")
}
response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+original, nil)
if response.StatusCode != http.StatusNotFound {
t.Errorf("the previous key still resolves: %d", response.StatusCode)
}
response.Body.Close()
response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil)
if response.StatusCode != http.StatusOK {
t.Errorf("the new key does not resolve: %d", response.StatusCode)
}
response.Body.Close()
})
phase(t, "the profile of another member still shows no picture", func(t *testing.T) {
type profile struct {
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
result := decode[profile](t, j.admin.get(host, "/api/v1/profile", nil), http.StatusOK)
if result.AvatarURL == "" {
t.Error("the administrator's picture disappeared")
}
})
phase(t, "removing the picture clears it everywhere", func(t *testing.T) {
key := j.avatarKey
type updated struct {
AvatarURL string `json:"avatar_url"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{"remove_avatar": true})
result := decode[updated](t, response, http.StatusOK)
if result.AvatarURL != "" {
t.Errorf("avatar_url = %q, want empty", result.AvatarURL)
}
response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil)
if response.StatusCode != http.StatusNotFound {
t.Errorf("the removed picture still resolves: %d", response.StatusCode)
}
response.Body.Close()
})
phase(t, "the member is moved to the self-service role", func(t *testing.T) {
// The role phase left this person holding a role that grants console.read.
// Moving them to the built-in self-service role is the flow that proves the
// console and the API agree about who may open it.
response := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.ungrantedMemberID+"/access", map[string]any{
"tenant_id": j.scopedTenantID, "role_id": j.userRoleID, "application_ids": []string{},
})
if response.StatusCode != http.StatusNoContent {
t.Fatalf("change role = %d: %s", response.StatusCode, bodyOf(t, response))
}
})
phase(t, "a member without console access is sent to their profile page", func(t *testing.T) {
// The ungranted member holds the self-service role, which grants
// profile.edit but not console.read.
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, "/login", nil)))
response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.get(tenantHost, "/", nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("console = %d, want a redirect: %s", response.StatusCode, bodyOf(t, response))
}
if got := location(t, response); got != "/profile" {
t.Errorf("redirect = %q, want /profile", got)
}
page := bodyOf(t, browser.get(tenantHost, "/profile", nil))
if !strings.Contains(page, "<html") {
t.Error("the profile page did not render")
}
})
phase(t, "a member without console access cannot read the dashboard", func(t *testing.T) {
browser := j.browser()
tenantHost := j.h.tenantHost(j.scopedTenant)
token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, "/login", nil)))
response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
statusOnly(t, browser.get(tenantHost, "/api/v1/dashboard", nil), http.StatusForbidden)
})
}

View file

@ -0,0 +1,279 @@
package behaviour
import (
"net/http"
"testing"
)
// rolePhases exercises the database-driven role and permission model: the shared
// catalog, tenant-defined roles, and the rule that a role may never grant more
// than the administrator already holds.
func rolePhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
phase(t, "the permission catalog lists the enforced capabilities", func(t *testing.T) {
type entry struct {
Name string `json:"name"`
Label string `json:"label"`
System bool `json:"system"`
Enforced bool `json:"enforced"`
Description string `json:"description"`
}
type listing struct {
Permissions []entry `json:"permissions"`
}
result := decode[listing](t, j.admin.get(host, "/api/v1/permissions", nil), http.StatusOK)
names := map[string]entry{}
for _, item := range result.Permissions {
names[item.Name] = item
}
for _, want := range []string{
"console.read", "users.read", "users.manage", "applications.manage",
"keys.manage", "contracts.manage", "roles.manage", "tenants.manage", "profile.edit",
} {
entry, ok := names[want]
if !ok {
t.Errorf("permission %q is missing from the catalog", want)
continue
}
if !entry.System || !entry.Enforced {
t.Errorf("permission %q is not marked system and enforced: %+v", want, entry)
}
if entry.Label == "" || entry.Description == "" {
t.Errorf("permission %q has no label or description the console can render", want)
}
}
})
phase(t, "a malformed permission name is refused", func(t *testing.T) {
for _, name := range []string{"Reports Export", "reports/export", "reports..export", ""} {
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
"name": name, "label": "Reports export",
})
statusOnly(t, response, http.StatusBadRequest)
}
})
phase(t, "a permission without a label is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
"name": "reports.export", "label": " ",
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a tenant administrator may extend the catalog", func(t *testing.T) {
// Adding a name does not enforce anything; it only makes the name grantable.
j.permission = "reports.export"
type created struct {
Name string `json:"name"`
Label string `json:"label"`
System bool `json:"system"`
Enforced bool `json:"enforced"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
"name": j.permission, "label": "Export reports", "description": "Download tenant reports",
})
permission := decode[created](t, response, http.StatusCreated)
if permission.Name != j.permission {
t.Errorf("name = %q", permission.Name)
}
if permission.System || permission.Enforced {
t.Errorf("a tenant permission must not be system or enforced: %+v", permission)
}
})
phase(t, "a duplicate permission name is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
"name": j.permission, "label": "Export reports again",
})
statusOnly(t, response, http.StatusConflict)
})
phase(t, "a built-in permission cannot be deleted", func(t *testing.T) {
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/console.read", nil,
map[string]string{"Origin": "http://" + host})
statusOnly(t, response, http.StatusConflict)
})
phase(t, "a tenant defines its own role", func(t *testing.T) {
type created struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
System bool `json:"system"`
Permissions []string `json:"permissions"`
MemberCount int `json:"member_count"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "reporter", "name": "Reporter",
"description": "Reads the console and exports reports",
"permissions": []string{"console.read", "users.read", j.permission},
})
role := decode[created](t, response, http.StatusCreated)
if role.Slug != "reporter" {
t.Errorf("slug = %q", role.Slug)
}
if role.System {
t.Error("a tenant role must not be marked as a system role")
}
if len(role.Permissions) != 3 {
t.Errorf("permissions = %v, want three", role.Permissions)
}
j.customRoleID = role.ID
})
phase(t, "a permission a role grants cannot be deleted", func(t *testing.T) {
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/"+j.permission, nil,
map[string]string{"Origin": "http://" + host})
statusOnly(t, response, http.StatusConflict)
})
phase(t, "a duplicate role slug is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "reporter", "name": "Reporter again",
"permissions": []string{"console.read"},
})
statusOnly(t, response, http.StatusConflict)
})
phase(t, "a malformed role slug or name is refused", func(t *testing.T) {
for _, payload := range []map[string]any{
{"tenant_id": j.scopedTenantID, "slug": "Reporter", "name": "Upper"},
{"tenant_id": j.scopedTenantID, "slug": "reporter_2", "name": "Underscore"},
{"tenant_id": j.scopedTenantID, "slug": "reporter2", "name": ""},
} {
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", payload)
statusOnly(t, response, http.StatusBadRequest)
}
})
phase(t, "a role naming an unknown permission is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "invented", "name": "Invented",
"permissions": []string{"not.a.real.permission"},
})
statusOnly(t, response, http.StatusBadRequest)
})
phase(t, "a role cannot grant a capability the caller does not hold", func(t *testing.T) {
// tenants.manage belongs to owners alone. A tenant administrator asking for a
// role that grants it must not be able to mint their way above their own
// authority, and must not even learn that the capability exists.
browser := j.browser()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": "interview@nmbs.auth.dev", "password": "admin123",
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
"tenant_id": j.tenantID, "slug": "escalation", "name": "Escalation",
"permissions": []string{"tenants.manage"},
})
if response.StatusCode != http.StatusNotFound && response.StatusCode != http.StatusBadRequest {
t.Fatalf("escalation attempt = %d, want a refusal: %s", response.StatusCode, bodyOf(t, response))
}
})
phase(t, "a role's permissions and name can be changed", func(t *testing.T) {
type updated struct {
Name string `json:"name"`
Slug string `json:"slug"`
Permissions []string `json:"permissions"`
}
response := j.admin.json(http.MethodPut, host, "/api/v1/roles/"+j.customRoleID, map[string]any{
"name": "Report reader", "description": "Now with fewer capabilities",
"permissions": []string{"console.read"},
})
role := decode[updated](t, response, http.StatusOK)
if role.Name != "Report reader" {
t.Errorf("name = %q", role.Name)
}
if len(role.Permissions) != 1 {
t.Errorf("permissions = %v, want just console.read", role.Permissions)
}
})
phase(t, "the permission is deletable once no role grants it", func(t *testing.T) {
// The update above left the custom permission unreferenced.
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/"+j.permission, nil,
map[string]string{"Origin": "http://" + host})
if response.StatusCode != http.StatusNoContent {
t.Fatalf("delete permission = %d: %s", response.StatusCode, bodyOf(t, response))
}
response.Body.Close()
})
phase(t, "a role still held by a member cannot be deleted", func(t *testing.T) {
// Give the custom role to the ungranted member, then try to remove it.
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.ungrantedMemberID+"/access", map[string]any{
"tenant_id": j.scopedTenantID, "role_id": j.customRoleID, "application_ids": []string{},
})
if access.StatusCode != http.StatusNoContent {
t.Fatalf("assign custom role = %d: %s", access.StatusCode, bodyOf(t, access))
}
response := j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+j.customRoleID, nil,
map[string]string{"Origin": "http://" + host})
statusOnly(t, response, http.StatusConflict)
})
phase(t, "an unused role is deleted, and its permission with it", func(t *testing.T) {
type created struct {
ID string `json:"id"`
}
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
"tenant_id": j.scopedTenantID, "slug": "temporary", "name": "Temporary",
"permissions": []string{"console.read"},
})
temporary := decode[created](t, response, http.StatusCreated)
response = j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+temporary.ID, nil,
map[string]string{"Origin": "http://" + host})
if response.StatusCode != http.StatusNoContent {
t.Fatalf("delete role = %d: %s", response.StatusCode, bodyOf(t, response))
}
response.Body.Close()
// The custom permission is now unreferenced, so it can go too.
response.Body.Close()
})
phase(t, "a built-in role cannot be edited or deleted", func(t *testing.T) {
type listing struct {
Roles []struct {
ID string `json:"id"`
Slug string `json:"slug"`
System bool `json:"system"`
} `json:"roles"`
}
result := decode[listing](t, j.admin.get(host, "/api/v1/roles?tenant_id="+j.scopedTenantID, nil), http.StatusOK)
var ownerID string
for _, role := range result.Roles {
if role.Slug == "owner" {
ownerID = role.ID
}
}
if ownerID == "" {
t.Fatal("the owner role is missing")
}
response := j.admin.json(http.MethodPut, host, "/api/v1/roles/"+ownerID, map[string]any{
"name": "Hijacked", "permissions": []string{"console.read"},
})
if response.StatusCode == http.StatusOK {
t.Error("a built-in role was renamed")
response.Body.Close()
} else {
response.Body.Close()
}
response = j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+ownerID, nil,
map[string]string{"Origin": "http://" + host})
if response.StatusCode == http.StatusNoContent {
t.Error("a built-in role was deleted")
response.Body.Close()
} else {
response.Body.Close()
}
})
}

View file

@ -0,0 +1,162 @@
package behaviour
import (
"net/http"
"strings"
"testing"
)
// securityPhases checks the defences that sit in front of every flow above.
func securityPhases(t *testing.T, j *journey) {
t.Helper()
host := j.adminHost()
phase(t, "a write from another origin is refused", func(t *testing.T) {
// The Origin header is what a browser sets and an attacker cannot forge, so
// this is the check that stops a cross-site form post.
for _, target := range []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/tenants"},
{http.MethodPut, "/api/v1/profile"},
{http.MethodPost, "/api/v1/permissions"},
{http.MethodPost, "/api/v1/applications/" + j.applicationID + "/client-config"},
} {
response := j.admin.request(target.method, host, target.path,
strings.NewReader(`{"slug":"csrf","name":"CSRF"}`), map[string]string{
"Content-Type": "application/json", "Origin": "https://evil.test",
})
if response.StatusCode != http.StatusForbidden {
t.Errorf("%s %s from a foreign origin = %d, want 403", target.method, target.path, response.StatusCode)
}
bodyOf(t, response)
}
})
phase(t, "a write with no origin or referrer is refused", func(t *testing.T) {
response := j.admin.request(http.MethodPost, host, "/api/v1/tenants",
strings.NewReader(`{"slug":"noorigin","name":"No Origin"}`), map[string]string{
"Content-Type": "application/json", "Origin": "", "Referer": "",
})
statusOnly(t, response, http.StatusForbidden)
})
phase(t, "the configured base URL is also accepted as its own origin", func(t *testing.T) {
// A deployment reached through a different hostname must still be able to
// write, or every legitimate form post fails.
response := j.admin.request(http.MethodPost, host, "/api/v1/tenants",
strings.NewReader(`{"slug":"viabaseurl","name":"Via Base URL"}`), map[string]string{
"Content-Type": "application/json", "Origin": j.h.baseURL,
})
if response.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", response.StatusCode, bodyOf(t, response))
}
})
phase(t, "responses carry the hardening headers", func(t *testing.T) {
response := j.admin.get(host, "/", nil)
bodyOf(t, response)
policy := response.Header.Get("Content-Security-Policy")
for _, want := range []string{"default-src 'self'", "connect-src 'self'", "img-src 'self'"} {
if !strings.Contains(policy, want) {
t.Errorf("Content-Security-Policy lacks %q: %s", want, policy)
}
}
if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q", got)
}
// Clickjacking is blocked with X-Frame-Options rather than frame-ancestors.
if got := response.Header.Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options = %q, want DENY", got)
}
if got := response.Header.Get("Referrer-Policy"); got != "same-origin" {
t.Errorf("Referrer-Policy = %q", got)
}
if got := response.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
t.Errorf("Permissions-Policy = %q", got)
}
})
phase(t, "the health check needs no session", func(t *testing.T) {
response := j.browser().get(j.h.apex(), "/healthz", nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d", response.StatusCode)
}
bodyOf(t, response)
})
phase(t, "a console API call without a session answers 401 rather than a redirect", func(t *testing.T) {
// An XHR that receives a 303 to the login page cannot tell it apart from a
// real result, so the API answers with a status the caller can act on.
for _, path := range []string{"/api/v1/dashboard", "/api/v1/profile", "/api/v1/permissions"} {
response := j.browser().get(host, path, nil)
statusOnly(t, response, http.StatusUnauthorized)
}
})
phase(t, "a console page without a session redirects to sign in", func(t *testing.T) {
for _, path := range []string{"/", "/profile", "/roles"} {
response := j.browser().get(host, path, nil)
if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" {
t.Errorf("GET %s = %d %q, want 303 to /login", path, response.StatusCode, response.Header.Get("Location"))
}
response.Body.Close()
}
})
phase(t, "signing out ends the management session", func(t *testing.T) {
browser := j.browser()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": localAdminMail, "password": localAdminPass,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d", response.StatusCode)
}
response.Body.Close()
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusOK)
response = browser.form(host, "/auth/logout", neturlValues(map[string]string{}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-out = %d: %s", response.StatusCode, bodyOf(t, response))
}
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusUnauthorized)
})
phase(t, "signing out ends the session when the provider has no logout endpoint", func(t *testing.T) {
// Google does not advertise end_session_endpoint, so the documented fallback
// applies: the local sign-out still happens and the browser returns to the
// sign-in page rather than to the provider.
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": localAdminMail, "password": localAdminPass,
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.form(host, "/auth/logout", neturlValues(map[string]string{}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("sign-out = %d: %s", response.StatusCode, bodyOf(t, response))
}
target := location(t, response)
if target != "/login" && !strings.Contains(target, "accounts.google.com") {
t.Errorf("sign-out redirect = %q, want the sign-in page or the provider", target)
}
if target == "/login" {
// The session must really be gone.
statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusUnauthorized)
}
})
phase(t, "an oversized profile upload is refused", func(t *testing.T) {
big := strings.Repeat("A", 5<<20)
response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]string{"avatar": big})
if response.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400", response.StatusCode)
}
bodyOf(t, response)
})
}

View file

@ -0,0 +1,77 @@
package behaviour
import (
"net/http"
"net/url"
"testing"
)
// neturlValues builds a form body from plain strings, so phases do not have to
// repeat the url.Values literal for every field.
func neturlValues(fields map[string]string) url.Values {
values := url.Values{}
for name, value := range fields {
values.Set(name, value)
}
return values
}
// tenantPhases exercises the installation-wide tenant management, which is the
// one console action restricted to system administrators.
func tenantPhases(t *testing.T, j *journey) {
t.Helper()
phase(t, "a system administrator creates a tenant", func(t *testing.T) {
type tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
response := j.admin.json(http.MethodPost, j.adminHost(), "/api/v1/tenants", map[string]string{
"slug": "behaviour", "name": "Behaviour Tenant",
})
created := decode[tenant](t, response, http.StatusCreated)
if created.Slug != "behaviour" {
t.Errorf("slug = %q", created.Slug)
}
if created.ID == "" {
t.Error("no tenant id returned")
}
// Later phases build their own application in this tenant, so it isolates
// authorization from the seeded demonstration tenant.
j.scopedTenant, j.scopedTenantID = created.Slug, created.ID
})
phase(t, "a duplicate tenant slug is refused", func(t *testing.T) {
response := j.admin.json(http.MethodPost, j.adminHost(), "/api/v1/tenants", map[string]string{
"slug": "behaviour", "name": "Duplicate",
})
statusOnly(t, response, http.StatusConflict)
})
phase(t, "a malformed tenant slug is refused", func(t *testing.T) {
for _, slug := range []string{"Has Uppercase", "has/slash", "-leading", ""} {
response := j.admin.json(http.MethodPost, j.adminHost(), "/api/v1/tenants", map[string]string{
"slug": slug, "name": "Invalid",
})
statusOnly(t, response, http.StatusBadRequest)
}
})
phase(t, "a tenant administrator cannot create a tenant", func(t *testing.T) {
// The seeded NMBS interviewer is a tenant administrator, not a system one.
browser := j.browser()
host := j.adminHost()
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
"csrf_token": token, "email": "interview@nmbs.auth.dev", "password": "admin123",
}), nil)
if response.StatusCode != http.StatusSeeOther {
t.Fatalf("seeded administrator sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
}
response = browser.json(http.MethodPost, host, "/api/v1/tenants", map[string]string{
"slug": "unauthorised", "name": "Unauthorised",
})
statusOnly(t, response, http.StatusForbidden)
})
}

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

@ -32,7 +32,45 @@ func Open(ctx context.Context, databaseURL string) (*sql.DB, error) {
return db, nil
}
// migrationLockKey serialises migration runs between instances of this service.
// It is deliberately distinct from the advisory lock used to claim the first
// system administrator.
const migrationLockKey int64 = 554628171918
// querier is the subset of database/sql that migrations need, satisfied by both
// *sql.DB and *sql.Conn.
type querier interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}
// Migrate brings the schema up to date.
//
// The whole run holds a session-level advisory lock, because PostgreSQL's
// CREATE TABLE IF NOT EXISTS is not race free: two instances starting against an
// empty database at the same time can collide on the pg_type unique index and one
// of them fails. The same race would let two instances apply the same migration
// concurrently. Rolling a deployment with more than one replica is enough to
// trigger it.
func Migrate(ctx context.Context, db *sql.DB) error {
conn, err := db.Conn(ctx)
if err != nil {
return fmt.Errorf("reserve migration connection: %w", err)
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrationLockKey); err != nil {
return fmt.Errorf("lock migrations: %w", err)
}
// The unlock runs on a context detached from the caller's, so an application
// shutting down mid-migration still releases the lock for the next instance.
defer func() {
_, _ = conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrationLockKey)
}()
return applyMigrations(ctx, conn)
}
func applyMigrations(ctx context.Context, db querier) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()

View file

@ -0,0 +1,45 @@
-- +goose Up
ALTER TABLE memberships DROP CONSTRAINT memberships_role_check;
ALTER TABLE memberships
ADD CONSTRAINT memberships_role_check
CHECK (role IN ('owner', 'admin', 'developer', 'operator', 'viewer', 'user'));
ALTER TABLE users
ADD COLUMN avatar_key text UNIQUE,
ADD COLUMN avatar bytea,
ADD COLUMN avatar_content_type text,
ADD COLUMN avatar_updated_at timestamptz,
ADD COLUMN name_customized boolean NOT NULL DEFAULT false,
ADD COLUMN profile_updated_at timestamptz;
-- The picture columns move together so that a key can never address a missing
-- or mismatched image.
ALTER TABLE users
ADD CONSTRAINT users_avatar_complete CHECK (
(avatar IS NULL AND avatar_key IS NULL AND avatar_content_type IS NULL AND avatar_updated_at IS NULL)
OR (avatar IS NOT NULL AND avatar_key IS NOT NULL AND avatar_content_type IS NOT NULL AND avatar_updated_at IS NOT NULL)
);
ALTER TABLE users
ADD CONSTRAINT users_avatar_content_type_known CHECK (
avatar_content_type IS NULL OR avatar_content_type IN ('image/jpeg', 'image/png', 'image/gif')
);
-- +goose Down
UPDATE memberships SET role = 'viewer' WHERE role IN ('developer', 'operator', 'user');
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_avatar_content_type_known,
DROP CONSTRAINT IF EXISTS users_avatar_complete;
ALTER TABLE users
DROP COLUMN IF EXISTS profile_updated_at,
DROP COLUMN IF EXISTS name_customized,
DROP COLUMN IF EXISTS avatar_updated_at,
DROP COLUMN IF EXISTS avatar_content_type,
DROP COLUMN IF EXISTS avatar,
DROP COLUMN IF EXISTS avatar_key;
ALTER TABLE memberships DROP CONSTRAINT memberships_role_check;
ALTER TABLE memberships
ADD CONSTRAINT memberships_role_check CHECK (role IN ('owner', 'admin', 'viewer'));

View file

@ -0,0 +1,106 @@
-- +goose Up
-- Roles become rows so a tenant can define its own. A NULL tenant_id marks a
-- built-in role: every tenant sees it and nobody may edit or delete it.
CREATE TABLE roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
name text NOT NULL CHECK (length(btrim(name)) 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(),
-- Custom roles belong to exactly one tenant; built-in roles to none.
CONSTRAINT roles_scope CHECK (
(is_system AND tenant_id IS NULL) OR (NOT is_system AND tenant_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX roles_system_slug_unique ON roles (slug) WHERE tenant_id IS NULL;
CREATE UNIQUE INDEX roles_tenant_slug_unique ON roles (tenant_id, slug) WHERE tenant_id IS NOT NULL;
-- The permission vocabulary is closed, so keep this list in step with the
-- constants in internal/role.
CREATE TABLE role_permissions (
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission text NOT NULL CHECK (permission IN (
'console.read', 'users.read', 'applications.manage', 'keys.manage',
'contracts.manage', 'users.manage', 'roles.manage', 'tenants.manage',
'profile.edit')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (role_id, permission)
);
INSERT INTO roles (slug, name, description, is_system) VALUES
('owner', 'Owner', 'Full control of the tenant, including roles, members, and tenant settings.', true),
('admin', 'Administrator', 'Manages applications, credentials, contracts, members, and roles.', true),
('developer', 'Developer', 'Manages applications, credentials, contracts, and members.', true),
('operator', 'Operator', 'Issues and revokes credentials, imports contracts, and manages members.', true),
('viewer', 'Viewer', 'Reads the console without changing anything.', true),
('user', 'User', 'Self-service member with no console access.', true);
-- Seed what used to be the Go matrix. developer and operator join owner and
-- admin in holding users.manage so that any tenant member who runs the console
-- can maintain their own tenant's members. roles.manage stays with owner and
-- admin: minting roles is what defines privilege in the first place.
INSERT INTO role_permissions (role_id, permission)
SELECT r.id, granted.permission
FROM (VALUES
('owner', ARRAY[
'console.read', 'users.read', 'applications.manage', 'keys.manage',
'contracts.manage', 'users.manage', 'roles.manage', 'tenants.manage',
'profile.edit']),
('admin', ARRAY[
'console.read', 'users.read', 'applications.manage', 'keys.manage',
'contracts.manage', 'users.manage', 'roles.manage', 'profile.edit']),
('developer', ARRAY[
'console.read', 'users.read', 'applications.manage', 'keys.manage',
'contracts.manage', 'users.manage', 'profile.edit']),
('operator', ARRAY[
'console.read', 'users.read', 'keys.manage', 'contracts.manage',
'users.manage', 'profile.edit']),
('viewer', ARRAY['console.read', 'users.read', 'profile.edit']),
('user', ARRAY['profile.edit'])
) AS seed(slug, permissions)
JOIN roles r ON r.slug = seed.slug AND r.tenant_id IS NULL
CROSS JOIN LATERAL unnest(seed.permissions) AS granted(permission);
-- Point every membership at a role row. RESTRICT rather than CASCADE so that a
-- role still held by somebody cannot be deleted out from under them.
ALTER TABLE memberships ADD COLUMN role_id uuid REFERENCES roles(id) ON DELETE RESTRICT;
UPDATE memberships m SET role_id = r.id
FROM roles r
WHERE r.tenant_id IS NULL AND r.slug = m.role;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM memberships WHERE role_id IS NULL) THEN
RAISE EXCEPTION 'cannot migrate memberships: a membership names a role that was never seeded';
END IF;
END
$$;
ALTER TABLE memberships ALTER COLUMN role_id SET NOT NULL;
ALTER TABLE memberships DROP CONSTRAINT memberships_role_check;
ALTER TABLE memberships DROP COLUMN role;
-- +goose Down
ALTER TABLE memberships ADD COLUMN role text;
UPDATE memberships m SET role = r.slug
FROM roles r
WHERE r.id = m.role_id AND r.is_system;
-- Custom roles have no equivalent in the old fixed vocabulary.
UPDATE memberships SET role = 'viewer' WHERE role IS NULL;
ALTER TABLE memberships DROP CONSTRAINT memberships_role_check;
ALTER TABLE memberships
ADD CONSTRAINT memberships_role_check
CHECK (role IN ('owner', 'admin', 'developer', 'operator', 'viewer', 'user'));
ALTER TABLE memberships ALTER COLUMN role SET NOT NULL;
ALTER TABLE memberships DROP COLUMN role_id;
DROP TABLE role_permissions;
DROP TABLE roles;

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;

132
internal/role/role.go Normal file
View file

@ -0,0 +1,132 @@
// Package role defines the permission vocabulary that authorizes console and
// management behavior.
//
// Roles are rows in the roles table and their permissions live in
// role_permissions, so a tenant can define its own and authorization is
// resolved by the database rather than from a matrix held here. What belongs in
// this package is the closed set of permissions a role may be granted, plus the
// labels the console renders.
//
// Keep the permission list in step with the role_permissions_permission_check
// constraint in migration 0006: a name that is not in both places cannot be
// stored.
package role
// Permission is a capability granted by a role.
type Permission string
const (
// ReadConsole opens the management console. Self-service members lack it so
// that they are routed to their profile instead.
ReadConsole Permission = "console.read"
// ReadUsers lists a tenant's members and their roles.
ReadUsers Permission = "users.read"
// ManageApplications registers applications and configures their client
// credentials.
ManageApplications Permission = "applications.manage"
// ManageKeys issues and revokes API keys.
ManageKeys Permission = "keys.manage"
// ManageContracts imports OpenAPI contracts.
ManageContracts Permission = "contracts.manage"
// ManageUsers invites members and changes the roles and application grants
// of existing ones.
ManageUsers Permission = "users.manage"
// ManageRoles creates and edits the tenant's own roles. Kept separate from
// ManageUsers because defining roles is what defines privilege.
ManageRoles Permission = "roles.manage"
// ManageTenants creates tenants and administers the installation.
ManageTenants Permission = "tenants.manage"
// EditProfile changes one's own display name and picture.
EditProfile Permission = "profile.edit"
)
// ordered lists every permission from most to least sensitive. The order is the
// one the console renders checkboxes in.
var ordered = []Permission{
ManageTenants,
ManageRoles,
ManageUsers,
ManageApplications,
ManageKeys,
ManageContracts,
ReadUsers,
ReadConsole,
EditProfile,
}
var known = map[Permission]bool{
ReadConsole: true,
ReadUsers: true,
ManageApplications: true,
ManageKeys: true,
ManageContracts: true,
ManageUsers: true,
ManageRoles: true,
ManageTenants: true,
EditProfile: true,
}
// Entry describes a permission for the console and for API clients.
type Entry struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description"`
}
var details = map[Permission]struct{ label, description string }{
ReadConsole: {"Read console", "Open the management console and pick tenants."},
ReadUsers: {"Read members", "List the members of a tenant and the roles they hold."},
ManageApplications: {"Manage applications", "Register applications and configure client credentials."},
ManageKeys: {"Manage API keys", "Issue and revoke API keys."},
ManageContracts: {"Manage contracts", "Import OpenAPI contracts and their endpoints."},
ManageUsers: {"Manage members", "Invite members and change their roles and application grants."},
ManageRoles: {"Manage roles", "Create and edit the roles of this tenant."},
ManageTenants: {"Manage tenants", "Create tenants and administer the installation."},
EditProfile: {"Edit own profile", "Change one's own display name and picture."},
}
// Permissions returns every permission in display order.
func Permissions() []Permission {
result := make([]Permission, len(ordered))
copy(result, ordered)
return result
}
// Parse validates a submitted permission name. Unknown values are rejected so
// that a crafted payload cannot invent capabilities.
func Parse(value string) (Permission, bool) {
candidate := Permission(value)
if !known[candidate] {
return "", false
}
return candidate, true
}
// Valid reports whether the value names a permission.
func (p Permission) Valid() bool {
return known[p]
}
// Catalog returns every permission with a label and description so that the
// console and API clients never hardcode the list.
func Catalog() []Entry {
result := make([]Entry, 0, len(ordered))
for _, permission := range ordered {
detail := details[permission]
result = append(result, Entry{
Value: string(permission),
Label: detail.label,
Description: detail.description,
})
}
return result
}
// Values returns every permission as strings, for use as a SQL array parameter.
func Values() []string {
result := make([]string, 0, len(ordered))
for _, permission := range ordered {
result = append(result, string(permission))
}
return result
}

117
internal/role/role_test.go Normal file
View file

@ -0,0 +1,117 @@
package role
import (
"slices"
"strings"
"testing"
)
func TestParseRejectsUnknownPermissions(t *testing.T) {
valid := []string{
"console.read", "users.read", "applications.manage", "keys.manage",
"contracts.manage", "users.manage", "roles.manage", "tenants.manage",
"profile.edit",
}
for _, value := range valid {
if _, ok := Parse(value); !ok {
t.Errorf("Parse(%q) = not ok, want a permission", value)
}
}
for _, value := range []string{"", "Console.read", "console", "superuser", "users.manage; DROP TABLE roles"} {
if parsed, ok := Parse(value); ok {
t.Errorf("Parse(%q) = %q, want rejection", value, parsed)
}
}
}
func TestValidMatchesParse(t *testing.T) {
for _, permission := range Permissions() {
if !permission.Valid() {
t.Errorf("%q reports itself invalid", permission)
}
if parsed, ok := Parse(string(permission)); !ok || parsed != permission {
t.Errorf("Parse(%q) did not round-trip", permission)
}
}
if Permission("nope").Valid() {
t.Error("an unknown permission reports itself valid")
}
}
func TestPermissionsAreDottedNames(t *testing.T) {
for _, permission := range Permissions() {
name := string(permission)
if len(name) < 3 || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
t.Fatalf("%q is not a plausible permission name", name)
}
dot := false
for _, character := range name {
switch {
case character == '.':
dot = true
case character >= 'a' && character <= 'z', character >= '0' && character <= '9', character == '-':
default:
t.Fatalf("%q contains %q; permissions use lowercase words", name, character)
}
}
if !dot {
t.Errorf("%q should be a namespaced name", name)
}
}
}
func TestCatalogDescribesEveryPermission(t *testing.T) {
catalog := Catalog()
if len(catalog) != len(Permissions()) {
t.Fatalf("catalog has %d entries, want %d", len(catalog), len(Permissions()))
}
seen := make(map[string]bool, len(catalog))
for _, entry := range catalog {
if entry.Value == "" || entry.Label == "" || entry.Description == "" {
t.Errorf("incomplete catalog entry %+v", entry)
}
if seen[entry.Value] {
t.Errorf("permission %q appears twice in the catalog", entry.Value)
}
seen[entry.Value] = true
}
}
// TestCatalogFollowsDisplayOrder guards the order the console renders the
// permission checkboxes in: the most sensitive capability first.
func TestCatalogFollowsDisplayOrder(t *testing.T) {
catalog := Catalog()
want := []string{"tenants.manage", "roles.manage", "users.manage"}
for index, value := range want {
if catalog[index].Value != value {
t.Errorf("catalog[%d] = %q, want %q", index, catalog[index].Value, value)
}
}
}
func TestPermissionsReturnsACopy(t *testing.T) {
first := Permissions()
if len(first) == 0 {
t.Fatal("no permissions")
}
first[0] = "mutated"
if Permissions()[0] == "mutated" {
t.Error("Permissions exposed its backing array")
}
}
func TestValuesMatchesPermissions(t *testing.T) {
values := Values()
permissions := Permissions()
if len(values) != len(permissions) {
t.Fatalf("Values has %d entries, want %d", len(values), len(permissions))
}
for index, permission := range permissions {
if values[index] != string(permission) {
t.Errorf("Values()[%d] = %q, want %q", index, values[index], permission)
}
}
if !slices.Contains(values, string(ManageUsers)) {
t.Error("users.manage is missing from Values")
}
}

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

@ -11,6 +11,31 @@ import (
"github.com/danyel/go-loose/internal/store"
)
// clientUserResponse is the identity payload handed to client applications. It
// carries the tenant role and its capabilities so that an application such as
// go-tell can gate features, plus an absolute avatar_url it can render directly.
type clientUserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
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(r, user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: user.Permissions,
}
}
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
@ -99,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": user,
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(r, user),
})
}
@ -118,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, 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

@ -39,3 +39,100 @@ func TestRequireSameOriginRejectsDifferentTenantHost(t *testing.T) {
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
}
}
// TestRequireSameOriginSurvivesStaleBaseURL covers the deployment that made
// profile saves fail with "same-origin request required": the container was
// started with a base URL of "http://localhost:" while users browsed to
// https://auth.urpi.be. The configured base URL must not be the only accepted
// origin, or a stale value breaks every state-changing route at once.
func TestRequireSameOriginSurvivesStaleBaseURL(t *testing.T) {
for _, baseURL := range []string{"http://localhost:", "", "http://localhost:8080", "not a url"} {
server := &Server{cfg: config.Config{BaseURL: baseURL}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodPut, "https://auth.urpi.be/api/v1/profile", nil)
request.Header.Set("Origin", "https://auth.urpi.be")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Errorf("base URL %q: status = %d, want %d", baseURL, response.Code, http.StatusNoContent)
}
}
}
// TestRequireSameOriginAcceptsForwardedHTTPS covers a reverse proxy that
// terminates TLS: the server sees a plain HTTP connection, so the scheme has to
// come from X-Forwarded-Proto rather than from r.TLS.
func TestRequireSameOriginAcceptsForwardedHTTPS(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "http://go-loose:8080"}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodPut, "http://auth.urpi.be/api/v1/profile", nil)
request.Host = "auth.urpi.be"
request.Header.Set("Origin", "https://auth.urpi.be")
request.Header.Set("X-Forwarded-Proto", "https")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
}
func TestRequireSameOriginFallsBackToReferer(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil)
request.Header.Set("Referer", "https://auth.dev/console")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
}
func TestRequireSameOriginRejectsMissingAndForeignSources(t *testing.T) {
cases := []struct {
name string
origin string
referer string
}{
{name: "no origin or referer"},
{name: "foreign origin", origin: "https://evil.example"},
{name: "null origin", origin: "null"},
{name: "same host different scheme", origin: "http://auth.dev"},
{name: "origin is a prefix of the host", origin: "https://auth.dev.evil.example"},
{name: "referer from another tenant", referer: "https://ypto.auth.dev/profile"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}}
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("handler reached for a request that was not same-origin")
}))
request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil)
if testCase.origin != "" {
request.Header.Set("Origin", testCase.origin)
}
if testCase.referer != "" {
request.Header.Set("Referer", testCase.referer)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
}
})
}
}

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)
}

163
internal/server/profile.go Normal file
View file

@ -0,0 +1,163 @@
package server
import (
"bytes"
"errors"
"net/http"
"strings"
"time"
"github.com/danyel/go-loose/internal/avatar"
"github.com/danyel/go-loose/internal/store"
)
// maxProfileBody bounds a profile update. It allows an avatar at its 2 MiB
// limit after base64 inflation plus the JSON envelope.
const maxProfileBody = 4 << 20
// maxDisplayName bounds a self-selected display name.
const maxDisplayName = 200
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Memberships []store.Membership `json:"memberships"`
IsSystemAdministrator bool `json:"is_system_administrator"`
NameCustomized bool `json:"name_customized"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at"`
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
}
func (s *Server) profilePage(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "profile.html")
}
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "load profile", err)
return
}
writeJSON(w, http.StatusOK, s.profileResponse(profile))
}
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var request struct {
DisplayName *string `json:"display_name"`
Avatar string `json:"avatar"`
RemoveAvatar bool `json:"remove_avatar"`
}
if !decodeJSONLimit(w, r, maxProfileBody, &request) {
return
}
update := store.ProfileUpdate{}
if request.DisplayName != nil {
name := strings.TrimSpace(*request.DisplayName)
if name == "" || len(name) > maxDisplayName {
writeError(w, http.StatusBadRequest, "display name must be between 1 and 200 characters")
return
}
update.DisplayName = &name
}
switch {
case request.RemoveAvatar && request.Avatar != "":
writeError(w, http.StatusBadRequest, "send either a new picture or remove_avatar, not both")
return
case request.RemoveAvatar:
update.RemoveAvatar = true
case strings.TrimSpace(request.Avatar) != "":
data, contentType, err := avatar.Parse(request.Avatar)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
key, err := avatar.NewKey()
if err != nil {
s.internalError(w, "generate picture key", err)
return
}
update.AvatarKey, update.AvatarData, update.ContentType = key, data, contentType
case update.DisplayName == nil:
writeError(w, http.StatusBadRequest, "a display name or a picture is required")
return
}
if err := s.store.UpdateProfile(r.Context(), claimsFrom(r).UserID, update); err != nil {
s.handleStoreError(w, "update profile", err)
return
}
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "reload profile", err)
return
}
writeJSON(w, http.StatusOK, s.profileResponse(profile))
}
// avatar serves a stored profile picture by its unguessable key.
//
// The route is deliberately public. A profile picture is rendered by other
// applications such as go-tell through a plain image tag, which cannot carry
// the authentication cookie, and the key is 256 bits of randomness rather than
// a user identifier. Nothing else about the user is exposed here.
func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if !avatar.ValidKey(key) {
writeError(w, http.StatusNotFound, "picture not found")
return
}
picture, err := s.store.Picture(r.Context(), key)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "picture not found")
return
}
s.internalError(w, "load profile picture", err)
return
}
w.Header().Set("Content-Type", picture.ContentType)
w.Header().Set("Cache-Control", "public, max-age=300")
w.Header().Set("ETag", avatar.ETag(picture.Data))
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
http.ServeContent(w, r, "", picture.UpdatedAt, bytes.NewReader(picture.Data))
}
func (s *Server) profileResponse(profile store.Profile) profileResponse {
return profileResponse{
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
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,
}
}
// 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 "/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

@ -0,0 +1,192 @@
package server
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
)
func profileTestServer() *Server {
return &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}}
}
func profileRequest(t *testing.T, body string) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPut, "https://auth.example.test/api/v1/profile", strings.NewReader(body))
claims := session.Claims{UserID: "user-1", Management: true}
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
}
// 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.avatarPath(nil); got != "" {
t.Fatalf("avatarPath(nil) = %q, want empty", got)
}
empty := ""
if got := server.avatarPath(&empty); got != "" {
t.Fatalf("avatarPath(empty) = %q, want empty", got)
}
key := "k"
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)
}
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)
}
}
// TestClientUserExposesRoleCapabilitiesAndPicture checks that the identity
// handed to a client application carries the capabilities the store resolved for
// the membership's role rather than anything the server infers.
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
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)},
AvatarKey: &key,
})
if payload.Role != "operator" {
t.Fatalf("role = %q", payload.Role)
}
if !slices.Contains(payload.Permissions, string(role.ManageKeys)) {
t.Fatalf("permissions = %v, want keys.manage", payload.Permissions)
}
if slices.Contains(payload.Permissions, string(role.ManageTenants)) {
t.Fatalf("permissions = %v, must not grant tenants.manage", payload.Permissions)
}
if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") {
t.Fatalf("avatar url = %q", payload.AvatarURL)
}
}
// TestClientUserWithoutMembershipIsLeastPrivileged documents the pass-through: a
// user with no membership is reported with the permissions of the built-in User
// role, which the store resolves, so an empty capability set reaches clients
// rather than an error.
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
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)
}
if !slices.Equal(payload.Permissions, []string{string(role.EditProfile)}) {
t.Fatalf("permissions = %v, want only profile.edit", payload.Permissions)
}
}
func TestAvatarRejectsMalformedKeyWithoutQueryingTheDatabase(t *testing.T) {
server := profileTestServer()
for _, key := range []string{"", "short", "../../etc/passwd", strings.Repeat("a", 44)} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/avatars/"+key, nil)
request.SetPathValue("key", key)
response := httptest.NewRecorder()
server.avatar(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("avatar(%q) status = %d, want %d", key, response.Code, http.StatusNotFound)
}
if body := response.Body.String(); strings.Contains(body, "avatar") {
t.Fatalf("avatar(%q) leaked internals: %s", key, body)
}
}
}
func TestUpdateProfileRejectsInvalidRequests(t *testing.T) {
server := profileTestServer()
picture := base64.StdEncoding.EncodeToString(tinyPNG(t))
tests := []struct {
name string
body string
}{
{name: "unknown field", body: `{"display_name":"Ana","admin":true}`},
{name: "two JSON objects", body: `{"display_name":"Ana"}{"display_name":"Bo"}`},
{name: "blank display name", body: `{"display_name":" "}`},
{name: "oversized display name", body: `{"display_name":"` + strings.Repeat("a", maxDisplayName+1) + `"}`},
{name: "picture and removal", body: `{"avatar":"` + picture + `","remove_avatar":true}`},
{name: "picture is not base64", body: `{"avatar":"!!!not base64!!!"}`},
{name: "picture is not an image", body: `{"avatar":"` + base64.StdEncoding.EncodeToString([]byte("<svg/>")) + `"}`},
{name: "picture media type mismatch", body: `{"avatar":"data:image/gif;base64,` + picture + `"}`},
{name: "nothing to update", body: `{}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
server.updateProfile(response, profileRequest(t, test.body))
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
}
})
}
}
func TestUpdateProfileRejectsOversizedBody(t *testing.T) {
server := profileTestServer()
body := `{"avatar":"` + strings.Repeat("A", maxProfileBody) + `"}`
response := httptest.NewRecorder()
server.updateProfile(response, profileRequest(t, body))
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func TestProfilePageIsEmbedded(t *testing.T) {
content, err := web.Files.ReadFile("profile.html")
if err != nil {
t.Fatalf("profile.html must be embedded: %v", err)
}
for _, marker := range []string{"avatar-file", "profile-form", "/assets/profile.js"} {
if !bytes.Contains(content, []byte(marker)) {
t.Fatalf("profile.html is missing %q", marker)
}
}
}
func tinyPNG(t *testing.T) []byte {
t.Helper()
canvas := image.NewRGBA(image.Rect(0, 0, 2, 2))
canvas.Set(0, 0, color.RGBA{R: 255, A: 255})
buffer := &bytes.Buffer{}
if err := png.Encode(buffer, canvas); err != nil {
t.Fatalf("encode png: %v", err)
}
return buffer.Bytes()
}

151
internal/server/roles.go Normal file
View file

@ -0,0 +1,151 @@
package server
import (
"errors"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/store"
)
const (
maxRoleName = 100
maxRoleDescription = 500
)
// rolesPage serves the role management screen.
func (s *Server) rolesPage(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "roles.html")
}
// listRoles serves the role management screen's data: every built-in role plus
// the tenant's own, each annotated with whether the caller may assign it.
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
tenantID, ok := s.requestTenant(w, r)
if !ok {
return
}
roles, err := s.store.ListRoles(r.Context(), claimsFrom(r).UserID, tenantID)
if err != nil {
s.handleStoreError(w, "list roles", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
}
// createRole adds a role to the tenant. The store refuses a role that grants
// more than the caller holds, so the endpoint does not have to restate that rule.
func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
if !decodeJSON(w, r, &request) {
return
}
slug := strings.TrimSpace(request.Slug)
name := strings.TrimSpace(request.Name)
description := strings.TrimSpace(request.Description)
if !validSlug(slug) {
writeError(w, http.StatusBadRequest, "slug must be lowercase letters, digits, and dashes")
return
}
if name == "" || len(name) > maxRoleName {
writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters")
return
}
if len(description) > maxRoleDescription {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
created, err := s.store.CreateRole(
r.Context(), claimsFrom(r).UserID, request.TenantID, slug, name, description, request.Permissions)
if err != nil {
if errors.Is(err, store.ErrRoleSlugTaken) {
writeError(w, http.StatusConflict, "a role with this slug already exists in this tenant")
return
}
if errors.Is(err, store.ErrUnknownPermission) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.handleStoreError(w, "create role", err)
return
}
writeJSON(w, http.StatusCreated, created)
}
// updateRole renames a role and replaces the permissions it grants.
func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) {
var request struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
if !decodeJSON(w, r, &request) {
return
}
name := strings.TrimSpace(request.Name)
description := strings.TrimSpace(request.Description)
if name == "" || len(name) > maxRoleName {
writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters")
return
}
if len(description) > maxRoleDescription {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
updated, err := s.store.UpdateRole(
r.Context(), claimsFrom(r).UserID, r.PathValue("id"), name, description, request.Permissions)
if err != nil {
if errors.Is(err, store.ErrUnknownPermission) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.handleStoreError(w, "update role", err)
return
}
writeJSON(w, http.StatusOK, updated)
}
// deleteRole removes one of the tenant's own roles.
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteRole(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil {
if errors.Is(err, store.ErrRoleInUse) {
writeError(w, http.StatusConflict, "reassign the members of this role before deleting it")
return
}
s.handleStoreError(w, "delete role", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// requestTenant reads the tenant a request targets, preferring an explicit
// tenant_id and falling back to the tenant named by the host so that the role
// screen works on a tenant subdomain.
func (s *Server) requestTenant(w http.ResponseWriter, r *http.Request) (string, bool) {
if tenantID := strings.TrimSpace(r.URL.Query().Get("tenant_id")); tenantID != "" {
return tenantID, true
}
slug := s.requestTenantSlug(r.Host)
if slug == "" {
writeError(w, http.StatusBadRequest, "tenant_id is required")
return "", false
}
tenants, err := s.store.ListTenants(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "resolve tenant", err)
return "", false
}
for _, tenant := range tenants {
if tenant.Slug == slug {
return tenant.ID, true
}
}
writeError(w, http.StatusNotFound, "resource not found or permission denied")
return "", false
}

View file

@ -0,0 +1,179 @@
package server
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path"
"strings"
"testing"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/web"
)
// withClaims injects a management session the way requireSession would, so a
// handler can be called directly without standing up a session manager.
func withClaims(request *http.Request, claims session.Claims) context.Context {
return context.WithValue(request.Context(), claimsKey, claims)
}
func TestRolesPageIsEmbedded(t *testing.T) {
content, err := web.Files.ReadFile("roles.html")
if err != nil {
t.Fatalf("roles.html must be embedded: %v", err)
}
for _, marker := range []string{
"role-list", "role-form", "permission-list", "tenant-context", "/assets/roles.js",
} {
if !bytes.Contains(content, []byte(marker)) {
t.Errorf("roles.html is missing %q", marker)
}
}
}
// TestRolesScriptIsServed guards the asset allowlist: roles.js is a new file, and
// the handler serves assets by exact name rather than by pattern.
func TestRolesScriptIsServed(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/roles.js", nil)
request.SetPathValue("name", "roles.js")
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
if !strings.Contains(response.Body.String(), "/api/v1/roles") {
t.Error("roles.js does not call the role endpoints")
}
}
func TestAssetAllowlistRejectsUnknownFiles(t *testing.T) {
server := profileTestServer()
for _, name := range []string{"admin.js", "roles.html", "app.js.map", "secret.env"} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
request.SetPathValue("name", name)
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusNotFound {
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
}
}
}
// TestAssetTraversalCollapsesToTheAllowlistedName documents that a traversal
// attempt cannot escape the allowlist: path.Base reduces it to the bare file
// name, which is then matched against the list like any other request.
func TestAssetTraversalCollapsesToTheAllowlistedName(t *testing.T) {
server := profileTestServer()
for _, name := range []string{"../roles.js", "../../etc/passwd", "..%2froles.js"} {
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
request.SetPathValue("name", name)
response := httptest.NewRecorder()
server.asset(response, request)
served := path.Base(name)
if served == "roles.js" {
if response.Code != http.StatusOK {
t.Errorf("asset(%q) status = %d, want the allowlisted file to serve", name, response.Code)
}
continue
}
if response.Code != http.StatusNotFound {
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
}
}
}
func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
cases := []struct {
name string
body string
}{
{"missing slug", `{"name":"Role","permissions":[]}`},
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
}
// 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()
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/api/v1/roles",
strings.NewReader(testCase.body))
request.SetPathValue("id", "")
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
response := httptest.NewRecorder()
server.createRole(response, request)
// The store would need a database, so a 400 proves validation ran first.
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
}
})
}
}
func TestListRolesRequiresATenant(t *testing.T) {
server := profileTestServer()
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/roles", nil)
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
response := httptest.NewRecorder()
server.listRoles(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
if !strings.Contains(response.Body.String(), "tenant_id") {
t.Errorf("body = %s, want it to name the missing parameter", response.Body)
}
}
// TestRoleRoutesAreRegistered documents the two wrappers the role routes rely
// on. The server under test is not installed, so requireSession redirects every
// session-guarded route to the installer; an unregistered route would answer 404
// instead, which is what makes this a registration check.
func TestRoleRoutesAreRegistered(t *testing.T) {
// Handler() wraps every route in the request logger, so this server needs a
// logger where the profile tests, which call handlers directly, do not.
server := &Server{
cfg: config.Config{BaseURL: "https://auth.example.test"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
handler := server.Handler()
cases := []struct {
method, path string
}{
{http.MethodGet, "/api/v1/roles?tenant_id=t"},
{http.MethodPost, "/api/v1/roles"},
{http.MethodPut, "/api/v1/roles/abc"},
{http.MethodDelete, "/api/v1/roles/abc"},
{http.MethodGet, "/roles"},
}
for _, testCase := range cases {
t.Run(testCase.method+" "+testCase.path, func(t *testing.T) {
request := httptest.NewRequest(testCase.method, "https://auth.example.test"+testCase.path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d from requireSession", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/install" {
t.Fatalf("location = %q, want /install", location)
}
})
}
}

View file

@ -1,6 +1,7 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@ -36,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
@ -69,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)
@ -79,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)
@ -107,6 +112,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /auth/logout", s.authLogout)
mux.HandleFunc("POST /auth/password", s.passwordLogin)
mux.HandleFunc("GET /assets/{name}", s.asset)
mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar)
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
mux.HandleFunc("POST /connect/token", s.clientToken)
@ -117,6 +123,10 @@ func (s *Server) Handler() http.Handler {
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
// browser favicon request would redirect to /install and rotate its CSRF cookie.
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
mux.Handle("GET /roles", s.requireSession(http.HandlerFunc(s.rolesPage)))
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile))))
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
@ -129,6 +139,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
mux.Handle("GET /api/v1/roles", s.requireSession(http.HandlerFunc(s.listRoles)))
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)))
}
@ -155,16 +172,26 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
claims := claimsFrom(r)
console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
return
}
if !allowed {
s.serveEmbedded(w, "waiting-room.html")
s.internalError(w, "check console access", err)
return
}
if console {
s.serveEmbedded(w, "index.html")
return
}
member, err := s.store.HasMembership(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check membership", err)
return
}
if member {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
s.serveEmbedded(w, "waiting-room.html")
}
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
@ -177,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" {
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" &&
name != "account.js" {
http.NotFound(w, r)
return
}
@ -190,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)
@ -322,21 +402,99 @@ 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.HasManagementAccess(r.Context(), claims.UserID)
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
s.internalError(w, "check console access", err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "waiting for administrator approval")
writeError(w, http.StatusForbidden, "this account has no console access")
return
}
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
@ -374,9 +532,24 @@ 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.avatarPath(users[index].AvatarKey)
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "permissions": catalog,
"is_system_administrator": isSystemAdministrator,
})
}
@ -401,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
}
@ -598,16 +775,24 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
})
}
// requireSameOrigin rejects state-changing requests that did not come from the
// page the session cookie belongs to.
//
// The comparison is made against the origin the request actually arrived on
// rather than against cfg.BaseURL alone. A deployment whose configured base URL
// disagrees with the address users browse to -- a reverse proxy terminating TLS,
// a tenant subdomain, or simply a stale GO_LOOSE_BASE_URL -- would otherwise
// reject every legitimate write. Deriving the scheme from the request keeps the
// check anchored to r.Host, which is the part an attacker cannot influence: the
// browser sets Origin itself and it always names the page's own host.
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
sourceURL, sourceErr := url.Parse(source)
baseURL, baseErr := url.Parse(s.cfg.BaseURL)
if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme ||
!strings.EqualFold(sourceURL.Host, r.Host) {
sourceURL, err := url.Parse(source)
if err != nil || !s.originAllowed(sourceURL, r) {
writeError(w, http.StatusForbidden, "same-origin request required")
return
}
@ -615,6 +800,56 @@ func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
})
}
// originAllowed reports whether source names the same origin as the request.
// The configured base URL is accepted as well so that installations which
// advertise a canonical address keep working when a request reaches the server
// through a different but still legitimate hostname.
func (s *Server) originAllowed(source *url.URL, r *http.Request) bool {
if source.Host == "" {
return false
}
baseScheme, baseHost := baseOrigin(s.cfg.BaseURL)
for _, candidate := range [][2]string{
{s.requestScheme(r), r.Host},
{baseScheme, baseHost},
} {
scheme, host := candidate[0], candidate[1]
if host == "" {
continue
}
if strings.EqualFold(source.Host, host) && strings.EqualFold(source.Scheme, scheme) {
return true
}
}
return false
}
// requestScheme reports the scheme the client used to reach this server. A
// reverse proxy terminates TLS before the request arrives, so the forwarded
// protocol takes precedence over the local connection state.
func (s *Server) requestScheme(r *http.Request) string {
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" {
if scheme, _, found := strings.Cut(forwarded, ","); found {
return strings.TrimSpace(scheme)
}
return strings.TrimSpace(forwarded)
}
if r.TLS != nil {
return "https"
}
return "http"
}
// baseOrigin splits a configured base URL into its scheme and host, returning
// empty strings when the value is missing or unparseable.
func baseOrigin(baseURL string) (scheme, host string) {
parsed, err := url.Parse(baseURL)
if err != nil {
return "", ""
}
return parsed.Scheme, parsed.Host
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
@ -703,7 +938,11 @@ func validSlug(value string) bool {
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
return decodeJSONLimit(w, r, 6<<20, target)
}
func decodeJSONLimit(w http.ResponseWriter, r *http.Request, limit int64, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {

View file

@ -138,12 +138,14 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
}
tenantSlug := s.requestTenantSlug(r.Host)
tenantLogin := tenantSlug != ""
loginPrompt := "admin@goloose:~"
loginContext := "SYSTEM ADMINISTRATION"
loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant."
passwordButton := "LOGIN"
if tenantLogin {
loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION"
loginMessage = "Sign in with your tenant database account."
loginPrompt = tenantSlug + "@goloose:~"
passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug)
}
page := strings.NewReplacer(
@ -153,6 +155,7 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
"{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton),
"{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()),
"{{LOGIN_PROMPT}}", loginPrompt,
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))

View file

@ -0,0 +1,170 @@
package server
import (
"fmt"
"strings"
"testing"
"github.com/danyel/go-loose/web"
)
// 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)
}
}
// contractBackedTokens maps this application's own colour token onto the shared
// contract role that carries the same meaning. The right-hand side is a bare role
// name because the variable prefix belongs to the contract.
var contractBackedTokens = map[string]string{
"--bg": "bg",
"--surface": "surface",
// go-loose's second surface step is the contract's raised surface.
"--surface2": "surface-raised",
"--text": "text",
"--muted": "muted",
"--accent": "accent",
// go-loose's secondary accent is the contract's secondary role: for every
// palette the two agree, tokyo's #bb9af7 and catppuccin's #cba6f7 among them.
"--accent2": "secondary",
"--good": "good",
"--danger": "danger",
"--border": "border",
}
// TestLocalTokensAdoptTheContractWhereItIsServed covers the difference between a
// theme selector that is present in the page and one that works.
//
// Every palette this application names has to resolve through the contract's
// variables, with this application's own value as the fallback. The fallback is
// what keeps the install and login pages styled as they always were: they never load
// the contract, so the variable is undefined there and the literal applies. Loading
// the contract on a console page therefore adopts the shared palette without
// changing anything for the pages that do not ask for it.
//
// Both directions are asserted. Adopting the role is not enough on its own: a single
// declaration left holding a bare literal overrides the contract for whichever
// palette that block names, and the console then renders half shared and half local.
func TestLocalTokensAdoptTheContractWhereItIsServed(t *testing.T) {
css, err := web.Files.ReadFile("style.css")
if err != nil {
t.Fatalf("style.css must be embedded: %v", err)
}
stylesheet := string(css)
for token, role := range contractBackedTokens {
if !strings.Contains(stylesheet, fmt.Sprintf("%s:var(--bn-%s,", token, role)) {
t.Errorf("token %s does not adopt --bn-%s, so selecting a palette outside this application's own four changes nothing:\n%s",
token, role, firstLines(css, 8))
}
// A literal assignment anywhere would win for the palette it sits under, and
// the contract's value for that one palette would be discarded.
if literal := token + ":#"; strings.Contains(stylesheet, literal) {
t.Errorf("token %s is still assigned a bare literal, which overrides the contract for the palette that block names:\n%s",
token, firstLines(css, 8))
}
}
}
// TestTheShadowTokenIsNotMapped guards the one token deliberately left alone.
// The contract's shadow family holds complete box-shadow values, and this token
// holds a colour, so wiring them together would substitute a shadow for a colour.
func TestTheShadowTokenIsNotMapped(t *testing.T) {
css, err := web.Files.ReadFile("style.css")
if err != nil {
t.Fatalf("style.css must be embedded: %v", err)
}
if strings.Contains(string(css), "--shadow:var(--bn-") {
t.Error("--shadow is a colour here and the contract's shadow roles are box-shadow values; mapping them would break every elevation")
}
}
// firstLines trims a stylesheet for an error message, since the token blocks sit at
// the top of the file and the whole of it is noise in a test failure.
func firstLines(css []byte, n int) string {
lines := strings.SplitN(string(css), "\n", n+1)
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}

View file

@ -59,7 +59,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
TenantID string `json:"tenant_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &request) {
@ -75,8 +75,8 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
if request.DisplayName == "" {
request.DisplayName = request.Email
}
if request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
if strings.TrimSpace(request.RoleID) == "" {
writeError(w, http.StatusBadRequest, "role_id is required")
return
}
passwordHash, err := password.Hash(request.Password)
@ -84,28 +84,29 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.RoleID, passwordHash)
if err != nil {
s.handleStoreError(w, "invite user", err)
return
}
user.AvatarURL = s.avatarPath(user.AvatarKey)
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
RoleID string `json:"role_id"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "invalid role")
if strings.TrimSpace(request.RoleID) == "" {
writeError(w, http.StatusBadRequest, "role_id is required")
return
}
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.RoleID, request.ApplicationIDs); err != nil {
s.handleStoreError(w, "update user access", err)
return
}
@ -115,19 +116,19 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
RoleID string `json:"role_id"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "invalid role")
if strings.TrimSpace(request.RoleID) == "" {
writeError(w, http.StatusBadRequest, "role_id is required")
return
}
if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
request.Role, request.ApplicationIDs,
request.RoleID, request.ApplicationIDs,
); err != nil {
s.handleStoreError(w, "approve pending user", err)
return

View file

@ -0,0 +1,121 @@
package store_test
import (
"errors"
"testing"
"github.com/danyel/go-loose/internal/store"
)
// TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications pins the
// boundary between the management plane and the client plane.
//
// Owning every tenant is what lets the first administrator reach the whole
// installation from the console. It is not permission to sign in to the
// applications running inside those tenants: ClientForAuthorization answers for a
// user_application_access row and nothing else, so a claim that inserted one per
// application would ship every installation with a standing administrator
// account inside every customer application. Access to an application is an
// explicit grant, and this test also pins that such a grant is still possible on
// purpose, because an administrator is allowed to attach themselves.
func TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications(t *testing.T) {
s, _, ctx := integrationStore(t)
// Two tenants, each with a client configured, so that "every application" is
// not satisfied by a single row.
firstOwner := newUser(t, s, ctx, "first@example.test")
first := newTenant(t, s, ctx, firstOwner, "nmbs")
secondOwner := newUser(t, s, ctx, "second@example.test")
second := newTenant(t, s, ctx, secondOwner, "ypto")
const redirect = "https://app.example.test/callback"
application := func(owner, tenantID, slug string) store.Application {
t.Helper()
created, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenantID, Slug: slug, Name: slug, AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application %s: %v", slug, err)
}
if _, err := s.ConfigureClient(ctx, owner, created.ID, []string{redirect}, secretHash(slug)); err != nil {
t.Fatalf("configure client login for %s: %v", slug, err)
}
return created
}
firstApp := application(firstOwner, first.ID, "guess")
secondApp := application(secondOwner, second.ID, "tell")
admin := newUser(t, s, ctx, "admin@example.test")
claimed, err := s.ClaimFirstSystemAdministrator(ctx, admin)
if err != nil {
t.Fatalf("claim first system administrator: %v", err)
}
if !claimed {
t.Fatal("the first user to sign in must become the system administrator")
}
// The management plane still reaches everything: the claim owns both tenants,
// which is what the console needs to administer them.
console, err := s.HasConsoleAccess(ctx, admin)
if err != nil {
t.Fatalf("HasConsoleAccess: %v", err)
}
if !console {
t.Error("a system administrator must have console access")
}
tenants, err := s.ListTenants(ctx, admin)
if err != nil {
t.Fatalf("ListTenants: %v", err)
}
if len(tenants) != 2 {
t.Errorf("the administrator owns %d tenants, want 2", len(tenants))
}
// The client plane does not. No application was granted, so neither endpoint
// that mints or resolves a hosted-login session will recognize them.
for _, test := range []struct {
name string
application store.Application
}{
{name: "nmbs", application: firstApp},
{name: "ypto", application: secondApp},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := s.ClientForAuthorization(ctx, test.application.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("ClientForAuthorization = %v, want ErrNotFound: owning a tenant must not open its applications", err)
}
if _, err := s.ClientUserByToken(ctx, secretHash("no-session")); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("ClientUserByToken = %v, want ErrNotFound", err)
}
})
}
// Attaching the administrator to one application is allowed, and it is the only
// way in. They hold users.manage in every tenant by virtue of owning it, which
// is what makes the deliberate act possible.
ownerRole, err := s.SystemRoleID(ctx, "owner")
if err != nil {
t.Fatalf("look up the owner role: %v", err)
}
if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, []string{firstApp.ID}); err != nil {
t.Fatalf("an administrator must be able to attach themselves to an application: %v", err)
}
if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); err != nil {
t.Fatalf("ClientForAuthorization after an explicit grant = %v, want the application", err)
}
// Withdrawing it takes effect immediately and leaves the console untouched.
if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, nil); err != nil {
t.Fatalf("withdraw the grant: %v", err)
}
if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("ClientForAuthorization after withdrawal = %v, want ErrNotFound", err)
}
console, err = s.HasConsoleAccess(ctx, admin)
if err != nil {
t.Fatalf("HasConsoleAccess: %v", err)
}
if !console {
t.Error("withdrawing an application grant must not cost the administrator their console")
}
}

View file

@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"time"
"github.com/danyel/go-loose/internal/role"
)
type ManagedUser struct {
@ -14,8 +16,14 @@ type ManagedUser struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Permissions []string `json:"permissions"`
Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"`
// AvatarKey is the stored locator. The server turns it into the absolute
// AvatarURL returned to clients.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
}
type ClientApplication struct {
@ -36,12 +44,27 @@ type ClientUser struct {
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
// Role is the membership role in the tenant owning the application. A user
// without a membership is reported as User so clients always see the least
// privileged role.
Role string `json:"role"`
// Permissions is read from the role's own rows. A user without a membership
// gets the permissions of the built-in User role.
Permissions []string `json:"permissions"`
// AvatarKey is the stored locator; the server expands it into AvatarURL.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
}
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
SELECT u.id, m.tenant_id, u.email, u.display_name, r.slug, m.role_id,
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
u.avatar_key,
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id
), '[]'::json),
COALESCE((
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
FROM user_application_access ua
@ -50,12 +73,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
), '[]'::json)
FROM users u
JOIN memberships m ON m.user_id = u.id
WHERE EXISTS (
SELECT 1 FROM memberships own
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
AND own.role IN ('owner', 'admin')
)
ORDER BY u.email, m.tenant_id`, administratorID)
JOIN roles r ON r.id = m.role_id
WHERE `+permissionPredicate("m.tenant_id", "$1", "$2")+`
ORDER BY u.email, m.tenant_id`, administratorID, string(role.ManageUsers))
if err != nil {
return nil, err
}
@ -63,8 +83,12 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
result := make([]ManagedUser, 0)
for rows.Next() {
var user ManagedUser
var applicationIDs []byte
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil {
var permissions, applicationIDs []byte
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
&user.RoleID, &user.Status, &user.AvatarKey, &permissions, &applicationIDs); err != nil {
return nil, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return nil, err
}
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
@ -75,23 +99,30 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
return result, rows.Err()
}
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
// InviteUser adds a member to a tenant or, when the email already exists,
// re-invites them. The caller must hold users.manage in that tenant and may only
// hand out a role whose permissions they hold themselves.
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, roleID string, passwordHash string) (ManagedUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ManagedUser{}, err
}
defer tx.Rollback()
var permitted bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
)`, tenantID, administratorID).Scan(&permitted); err != nil {
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return ManagedUser{}, err
}
if !permitted {
return ManagedUser{}, ErrNotFound
}
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
}
var user ManagedUser
err = tx.QueryRowContext(ctx, `
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
@ -107,37 +138,70 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
return ManagedUser{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, user.ID, role); err != nil {
INSERT INTO memberships(tenant_id, user_id, role_id) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, user.ID, resolved); err != nil {
return ManagedUser{}, err
}
if err := tx.Commit(); err != nil {
return ManagedUser{}, err
}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
granted, err := roleGrants(ctx, s.db, resolved)
if err != nil {
return ManagedUser{}, err
}
user.TenantID, user.RoleID, user.ApplicationIDs = tenantID, resolved, []string{}
if err := s.db.QueryRowContext(ctx, `SELECT slug FROM roles WHERE id = $1`, resolved).Scan(&user.Role); err != nil {
return ManagedUser{}, err
}
user.Permissions = granted
return user, nil
}
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
// SetUserAccess changes a member's role and replaces their application grants.
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return err
}
if !permitted {
return ErrNotFound
}
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
}
if err := ensureTenantKeepsAnOwner(ctx, tx, tenantID, userID, resolved); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `
UPDATE memberships target SET role = $4
FROM memberships administrator
WHERE target.tenant_id = $1 AND target.user_id = $2
AND administrator.tenant_id = target.tenant_id
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`,
tenantID, userID, administratorID, role)
UPDATE memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2 AND role_id IS DISTINCT FROM $3`,
tenantID, userID, resolved)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
// Either the membership does not exist or the role is unchanged.
var exists bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)`,
tenantID, userID).Scan(&exists); err != nil {
return err
}
if !exists {
return ErrNotFound
}
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM user_application_access ua
USING applications a
@ -160,17 +224,49 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
return tx.Commit()
}
// ErrLastOwner is returned when a change would leave a tenant without an owner.
var ErrLastOwner = errors.New("a tenant must keep at least one owner")
// ensureTenantKeepsAnOwner blocks demoting or moving away the only membership
// that still holds the owner role. Without this a tenant administrator could
// lock every administrator out of their own tenant with a single edit.
func ensureTenantKeepsAnOwner(ctx context.Context, tx *sql.Tx, tenantID, userID, newRoleID string) error {
var losesOwner bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND m.user_id = $2 AND r.slug = 'owner' AND m.role_id <> $3
)`, tenantID, userID, newRoleID).Scan(&losesOwner); err != nil {
return err
}
if !losesOwner {
return nil
}
var remaining int
if err := tx.QueryRowContext(ctx, `
SELECT count(*)
FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND r.slug = 'owner' AND m.user_id <> $2`,
tenantID, userID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
return ErrLastOwner
}
return nil
}
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
var app Application
var encoded []byte
err := s.db.QueryRowContext(ctx, `
UPDATE applications a
SET redirect_uris = $3, client_secret_hash = $4, updated_at = now()
FROM memberships m
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
AND m.role IN ('owner', 'admin')
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$2", "$3")+`
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
applicationID, administratorID, redirectURIs, secretHash).
applicationID, administratorID, string(role.ManageApplications), redirectURIs, secretHash).
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound
@ -244,22 +340,33 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
}
defer tx.Rollback()
var user ClientUser
var permissions []byte
err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now()
FROM applications a, tenants t, users u
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
LEFT JOIN roles r ON r.id = m.role_id
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
AND c.redirect_uri = $2 AND a.id = c.application_id
AND ua.user_id = c.user_id AND ua.application_id = a.id
AND a.client_id = $3 AND a.client_secret_hash = $4
AND t.id = a.tenant_id AND u.id = c.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
codeHash, redirectURI, clientID, secretHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound
}
if err != nil {
return ClientUser{}, time.Time{}, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return ClientUser{}, time.Time{}, err
}
expiresAt := time.Now().UTC().Add(12 * time.Hour)
if _, err := tx.ExecContext(ctx, `
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
@ -271,18 +378,32 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
var user ClientUser
var permissions []byte
err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now()
FROM applications a, tenants t, users u, user_application_access ua
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
LEFT JOIN roles r ON r.id = m.role_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id
AND ua.application_id = a.id AND ua.user_id = u.id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
AND a.id = s.application_id AND ua.user_id = s.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
tokenHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound
}
return user, err
if err != nil {
return ClientUser{}, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return ClientUser{}, err
}
return user, nil
}
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {
@ -297,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

@ -4,12 +4,15 @@ import (
"context"
"database/sql"
"errors"
"github.com/danyel/go-loose/internal/role"
)
type Installation struct {
OIDCIssuer string
OIDCClientID string
OIDCSecretCiphertext []byte
OIDCRedirectURL string
SeededDemo bool
}
@ -23,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
}
@ -39,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 {
@ -78,8 +81,11 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil {
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
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, userID, "admin"); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
@ -92,6 +98,13 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
return tx.Commit()
}
// ClaimFirstSystemAdministrator promotes the first user to sign in to system
// administrator and gives them ownership of every tenant that exists at that
// moment. It is a no-op once an administrator exists.
//
// It grants console reach across the installation and nothing more: no
// application grant, so the new administrator cannot sign in to a tenant
// application until one is deliberately given to them.
func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@ -112,23 +125,41 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT id, $1, 'owner' FROM tenants 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 {
INSERT INTO memberships(tenant_id, user_id, role_id)
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
}
// Deliberately no application grant. Owning every tenant is what lets the
// administrator administer it; it is not a licence to enter the applications
// running inside it. Access to a client application is only ever an explicit
// grant, so an administrator who wants to use one attaches themselves the same
// way anybody else does, visibly, from the console, and it can be withdrawn
// again without touching the tenant. Granting it here instead would hand every
// installation a standing administrator account inside every customer
// application, which is the opposite of the boundary this product draws.
return true, tx.Commit()
}
func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) {
// HasConsoleAccess reports whether the user may read the management console.
// System administrators always qualify; otherwise one of their memberships must
// grant console.read, which excludes self-service User members.
func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed)
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
userID, string(role.ReadConsole)).Scan(&allowed)
return allowed, err
}
// HasMembership reports whether the user belongs to any tenant. A member without
// console access is a self-service user rather than a waiting-room visitor.
func (s *Store) HasMembership(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).
Scan(&allowed)
return allowed, err
}
@ -167,7 +198,11 @@ func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([
return result, rows.Err()
}
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
// ApprovePendingUser gives a waiting-room visitor their first membership.
// Only a system administrator may do this, so the escalation guard that
// normally limits role assignment does not apply: the administrator is acting
// across tenants rather than inside one.
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
if err != nil {
return err
@ -180,11 +215,17 @@ func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantI
return err
}
defer tx.Rollback()
if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, id, $3 FROM users WHERE id = $2
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, userID, roleID)
if err != nil {
return err
}
if err != nil {
return err
}

View file

@ -0,0 +1,669 @@
package store_test
import (
"context"
"database/sql"
"errors"
"net/url"
"os"
"strings"
"testing"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// integrationStore opens the disposable PostgreSQL database named by
// GO_LOOSE_TEST_DATABASE_URL, drops every table, and applies all migrations. The
// test is skipped when the variable is absent so unit runs never touch a
// developer's data.
func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
t.Helper()
url := os.Getenv("GO_LOOSE_TEST_DATABASE_URL")
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 {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { 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)
}
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.
func grantApplicationAccess(t *testing.T, db *sql.DB, ctx context.Context, applicationID, userID string) {
t.Helper()
if _, err := db.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
VALUES ($1, $2, $2)`, applicationID, userID); err != nil {
t.Fatalf("grant application access: %v", err)
}
}
func contractDocument() contract.Document {
return contract.Document{
Version: "3.1.0",
JSON: []byte(`{"openapi":"3.1.0","paths":{"/health":{"get":{"operationId":"health"}}}}`),
Endpoints: []contract.Endpoint{{Method: "GET", Path: "/health", OperationID: "health"}},
}
}
// resetSchema drops every table in the public schema, including
// schema_migrations, so that Migrate replays the full history from scratch.
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
}
defer rows.Close()
tables := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
tables = append(tables, `"`+name+`"`)
}
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
}
// newUser creates or reuses an account and returns its id.
func newUser(t *testing.T, s *store.Store, ctx context.Context, email string) string {
t.Helper()
user, err := s.UpsertUser(ctx, "test:"+email, email, strings.ToUpper(email[:1])+email[1:])
if err != nil {
t.Fatalf("upsert %s: %v", email, err)
}
return user.ID
}
// newTenant creates a tenant owned by owner and registers one application.
func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug string) store.Tenant {
t.Helper()
tenant, err := s.CreateTenant(ctx, owner, slug, strings.ToUpper(slug))
if err != nil {
t.Fatalf("create tenant %s: %v", slug, err)
}
return tenant
}
// addMember invites a member into the tenant with the given built-in role.
func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string {
t.Helper()
roleID, err := s.SystemRoleID(ctx, membershipRole)
if err != nil {
t.Fatalf("look up role %q: %v", membershipRole, err)
}
member, err := s.InviteUser(ctx, owner, tenantID, email, email, roleID, "not-a-real-hash")
if err != nil {
t.Fatalf("invite %s as %s: %v", email, membershipRole, err)
}
if member.Role != membershipRole {
t.Fatalf("invited role = %q, want %q", member.Role, membershipRole)
}
return member.ID
}
func TestMigrationsApplyAndRolesAreAccepted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
for _, item := range []string{"developer", "operator", "user"} {
addMember(t, s, ctx, owner, tenant.ID, item+"@example.test", item)
}
if _, err := s.ListTenants(ctx, owner); err != nil {
t.Fatalf("list tenants: %v", err)
}
}
func TestUnknownRoleIDIsRefused(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "00000000-0000-0000-0000-000000000000", nil); err == nil {
t.Fatal("assigning a role that does not exist must be refused")
}
}
func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("owner cannot create an application: %v", err)
}
tests := []struct {
role string
canCreateApp bool
canIssueKey bool
canSaveContract bool
canListManaged bool
canConfigureClien bool
}{
// owner and admin administer members; developer and operator may also
// maintain their own tenant's members, which is why they can list them.
{role: "owner", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "admin", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "developer", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "operator", canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: false},
{role: "viewer", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
{role: "user", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
}
for _, test := range tests {
t.Run(test.role, func(t *testing.T) {
member := addMember(t, s, ctx, owner, tenant.ID, test.role+"@example.test", test.role)
slug := strings.ReplaceAll(test.role, "owner", "own")
_, err := s.CreateApplication(ctx, member, store.Application{
TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{},
})
assertPermission(t, "create application", err, test.canCreateApp)
_, err = s.CreateAPIKey(ctx, member, application.ID, slug+"-key", "gl_prefix", secretHash(slug), nil)
assertPermission(t, "issue key", err, test.canIssueKey)
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
assertPermission(t, "import contract", err, test.canSaveContract)
managed, err := s.ListManagedUsers(ctx, member)
if err != nil {
t.Fatalf("list managed users: %v", err)
}
if test.canListManaged != (len(managed) > 0) {
t.Fatalf("users.read for %s: got %d members, want non-empty=%v", test.role, len(managed), test.canListManaged)
}
_, err = s.ConfigureClient(ctx, member, application.ID, []string{"https://app.example.test/callback"}, secretHash(slug))
assertPermission(t, "configure client login", err, test.canConfigureClien)
})
}
}
func TestConsoleAccessExcludesSelfServiceMembers(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", "user")
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", "operator")
waiting := newUser(t, s, ctx, "waiting@example.test")
for _, test := range []struct {
name string
userID string
console bool
member bool
}{
{name: "owner", userID: owner, console: true, member: true},
{name: "operator", userID: operator, console: true, member: true},
{name: "self-service user", userID: selfService, console: false, member: true},
{name: "waiting room", userID: waiting, console: false, member: false},
} {
t.Run(test.name, func(t *testing.T) {
console, err := s.HasConsoleAccess(ctx, test.userID)
if err != nil {
t.Fatalf("HasConsoleAccess: %v", err)
}
if console != test.console {
t.Fatalf("HasConsoleAccess = %v, want %v", console, test.console)
}
member, err := s.HasMembership(ctx, test.userID)
if err != nil {
t.Fatalf("HasMembership: %v", err)
}
if member != test.member {
t.Fatalf("HasMembership = %v, want %v", member, test.member)
}
})
}
}
func TestTenantPermissionsComeFromTheRoleMatrix(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", "developer")
tenants, err := s.ListTenants(ctx, developer)
if err != nil {
t.Fatalf("list tenants: %v", err)
}
if len(tenants) != 1 || tenants[0].Role != "developer" {
t.Fatalf("tenants = %+v", tenants)
}
if !contains(tenants[0].Permissions, string(role.ManageApplications)) {
t.Fatalf("developer permissions = %v", tenants[0].Permissions)
}
// developer administers members but must not mint roles, which is what
// defines privilege in the first place.
if !contains(tenants[0].Permissions, string(role.ManageUsers)) {
t.Fatalf("developer permissions = %v, want users.manage", tenants[0].Permissions)
}
if contains(tenants[0].Permissions, string(role.ManageRoles)) {
t.Fatalf("developer permissions = %v, must not manage roles", tenants[0].Permissions)
}
}
func TestProfileRoundTrip(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
before, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if before.AvatarKey != nil || before.NameCustomized {
t.Fatalf("fresh profile = %+v", before)
}
if len(before.Memberships) != 1 || before.Memberships[0].Role != "operator" {
t.Fatalf("memberships = %+v", before.Memberships)
}
if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) {
t.Fatalf("membership permissions = %v", before.Memberships[0].Permissions)
}
name := "Renamed By User"
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{DisplayName: &name}); err != nil {
t.Fatalf("rename: %v", err)
}
after, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile after rename: %v", err)
}
if after.DisplayName != name || !after.NameCustomized || after.ProfileUpdatedAt == nil {
t.Fatalf("profile = %+v", after)
}
}
func TestLoginDoesNotOverwriteACustomizedDisplayName(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
name := "My Chosen Name"
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{DisplayName: &name}); err != nil {
t.Fatalf("rename: %v", err)
}
for range 2 {
if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
}
profile, err := s.Profile(ctx, user)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.DisplayName != name {
t.Fatalf("display name = %q, want %q", profile.DisplayName, name)
}
}
func TestUncustomizedDisplayNameStillFollowsTheIdentityProvider(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
profile, err := s.Profile(ctx, user)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.DisplayName != "Identity Provider Name" {
t.Fatalf("display name = %q", profile.DisplayName)
}
}
func TestPictureIsStoredAndReadableByKey(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{
AvatarKey: key, AvatarData: payload, ContentType: "image/png",
}); err != nil {
t.Fatalf("store picture: %v", err)
}
picture, err := s.Picture(ctx, key)
if err != nil {
t.Fatalf("load picture: %v", err)
}
if picture.ContentType != "image/png" || string(picture.Data) != string(payload) {
t.Fatalf("picture = %+v", picture)
}
if picture.UpdatedAt.IsZero() {
t.Fatal("picture must record when it was stored")
}
profile, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if profile.AvatarKey == nil || *profile.AvatarKey != key || profile.AvatarUpdatedAt == nil {
t.Fatalf("profile avatar = %+v", profile)
}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{RemoveAvatar: true}); err != nil {
t.Fatalf("remove picture: %v", err)
}
if _, err := s.Picture(ctx, key); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Picture() error = %v, want ErrNotFound", err)
}
cleared, err := s.Profile(ctx, member)
if err != nil {
t.Fatalf("profile: %v", err)
}
if cleared.AvatarKey != nil || cleared.AvatarUpdatedAt != nil {
t.Fatalf("profile still advertises a picture: %+v", cleared)
}
}
func TestReplacingAPictureRetiresTheOldKey(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
first := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
second := "b3JkZXJ5a2V5Zm9ydGhlU2Vjb25kVXBvZGF0ZVRlc3QxMjM0NQ"
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: first, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil {
t.Fatalf("store first picture: %v", err)
}
if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: second, AvatarData: []byte("b"), ContentType: "image/png"}); err != nil {
t.Fatalf("store second picture: %v", err)
}
if _, err := s.Picture(ctx, first); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("the replaced key must stop resolving, got %v", err)
}
picture, err := s.Picture(ctx, second)
if err != nil || string(picture.Data) != "b" {
t.Fatalf("picture = %+v, err = %v", picture, err)
}
}
func TestPictureKeysAreUnique(t *testing.T) {
s, _, ctx := integrationStore(t)
first, second := newUser(t, s, ctx, "one@example.test"), newUser(t, s, ctx, "two@example.test")
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
if err := s.UpdateProfile(ctx, first, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil {
t.Fatalf("store first picture: %v", err)
}
if err := s.UpdateProfile(ctx, second, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("b"), ContentType: "image/png"}); err == nil {
t.Fatal("two users must not be able to claim the same picture key")
}
}
func TestIncompletePictureIsRejectedByTheDatabase(t *testing.T) {
s, _, ctx := integrationStore(t)
user := newUser(t, s, ctx, "person@example.test")
err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"})
if err != nil {
t.Fatalf("an update without picture bytes is a no-op, got %v", err)
}
if _, err := s.Picture(ctx, "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Picture() error = %v, want ErrNotFound", err)
}
}
func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{
AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl",
AvatarData: []byte("a"), ContentType: "image/png",
}); err != nil {
t.Fatalf("store picture: %v", err)
}
grantApplicationAccess(t, db, ctx, application.ID, member)
if err := s.CreateAuthorizationCode(ctx, application.ID, member, "https://app.example.test/callback", []byte("code-hash")); err != nil {
t.Fatalf("create authorization code: %v", err)
}
user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/callback", []byte("hash"), []byte("code-hash"), []byte("token-hash"))
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
if user.Role != "operator" {
t.Fatalf("role = %q, want operator", user.Role)
}
if user.AvatarKey == nil {
t.Fatal("the client payload must expose the picture locator")
}
byToken, err := s.ClientUserByToken(ctx, []byte("token-hash"))
if err != nil {
t.Fatalf("client user by token: %v", err)
}
if byToken.Role != "operator" || byToken.AvatarKey == nil {
t.Fatalf("client user = %+v", byToken)
}
}
func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
orphan := newUser(t, s, ctx, "orphan@example.test")
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/cb"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
grantApplicationAccess(t, db, ctx, application.ID, orphan)
if err := s.CreateAuthorizationCode(ctx, application.ID, orphan, "https://app.example.test/cb", []byte("code")); err != nil {
t.Fatalf("create authorization code: %v", err)
}
user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/cb", []byte("hash"), []byte("code"), []byte("token"))
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
if user.Role != "user" {
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, "user")
}
}
func assertPermission(t *testing.T, operation string, err error, allowed bool) {
t.Helper()
switch {
case allowed && err != nil:
t.Fatalf("%s must be allowed, got %v", operation, err)
case !allowed && !errors.Is(err, store.ErrNotFound):
t.Fatalf("%s must be denied with ErrNotFound, got %v", operation, err)
}
}
// secretHash builds a distinct digest per key name because secret_hash is unique.
func secretHash(name string) []byte {
return []byte("sha256-of-" + name)
}
func contains(values []string, want string) bool {
for _, item := range values {
if item == want {
return true
}
}
return false
}
// TestBootstrapGrantsOwnershipOnlyWhileTheTenantHasNone pins the rule that a
// bootstrapped account becomes a tenant's owner only when nobody owns it yet. The
// installer's demonstration seed creates tenants that already hold members, and
// refusing ownership there used to leave a local administrator with an account
// that could never reach the console.
func TestBootstrapGrantsOwnershipOnlyWhileTheTenantHasNone(t *testing.T) {
s, db, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "bootstrap")
// Leave the tenant holding administrators but no owner, which is the state the
// installer's demonstration seed produces.
adminRole, err := s.SystemRoleID(ctx, "admin")
if err != nil {
t.Fatalf("look up the admin role: %v", err)
}
if _, err := db.ExecContext(ctx,
`UPDATE memberships SET role_id = $1 WHERE tenant_id = $2`, adminRole, tenant.ID); err != nil {
t.Fatalf("demote the owner: %v", err)
}
addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "admin")
late := newUser(t, s, ctx, "late@example.test")
if err := s.Bootstrap(ctx, late, tenant.Slug, "guess"); err != nil {
t.Fatalf("bootstrap into a tenant with no owner: %v", err)
}
assertRole(t, db, ctx, late, tenant.ID, "owner")
// A further account must not displace the owner that now exists.
third := newUser(t, s, ctx, "third@example.test")
if err := s.Bootstrap(ctx, third, tenant.Slug, "guess"); err != nil {
t.Fatalf("bootstrap again: %v", err)
}
var memberships int
if err := db.QueryRowContext(ctx, `
SELECT count(*) FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND r.slug = 'owner'`, tenant.ID).Scan(&memberships); err != nil {
t.Fatalf("count owners: %v", err)
}
if memberships != 1 {
t.Errorf("owners = %d, want exactly one", memberships)
}
var thirdMemberships int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM memberships WHERE user_id = $1`, third).Scan(&thirdMemberships); err != nil {
t.Fatalf("count memberships: %v", err)
}
if thirdMemberships != 0 {
t.Errorf("a later bootstrap joined a tenant that already had an owner")
}
}
func assertRole(t *testing.T, db *sql.DB, ctx context.Context, userID, tenantID, want string) {
t.Helper()
var got string
err := db.QueryRowContext(ctx, `
SELECT r.slug FROM memberships m JOIN roles r ON r.id = m.role_id
WHERE m.user_id = $1 AND m.tenant_id = $2`, userID, tenantID).Scan(&got)
if err != nil {
t.Fatalf("read membership: %v", err)
}
if got != want {
t.Errorf("role = %q, want %q", got, want)
}
}
// TestCreateTenantReportsADuplicateSlug pins that a slug already in use is a
// conflict the caller can act on rather than an opaque server error.
func TestCreateTenantReportsADuplicateSlug(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if _, err := s.CreateTenant(ctx, owner, "nmbs", "Duplicate"); !errors.Is(err, store.ErrTenantSlugTaken) {
t.Fatalf("CreateTenant with a used slug = %v, want ErrTenantSlugTaken", err)
}
}
// TestConcurrentMigrationRunsDoNotCollide pins that instances starting together
// against an empty database all succeed. CREATE TABLE IF NOT EXISTS is not race
// free in PostgreSQL, so the run is serialised with an advisory lock.
func TestConcurrentMigrationRunsDoNotCollide(t *testing.T) {
databaseURL := os.Getenv("GO_LOOSE_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
requireScratchDatabase(t, databaseURL)
resetSchema(context.Background(), mustOpen(t, databaseURL))
const instances = 4
connections := make([]*sql.DB, instances)
for index := range connections {
connections[index] = mustOpen(t, databaseURL)
}
errs := make(chan error, instances)
for _, connection := range connections {
go func(conn *sql.DB) { errs <- database.Migrate(context.Background(), conn) }(connection)
}
for range connections {
if err := <-errs; err != nil {
t.Errorf("concurrent Migrate: %v", err)
}
}
for _, connection := range connections {
var applied int
if err := connection.QueryRowContext(context.Background(),
`SELECT count(*) FROM schema_migrations`).Scan(&applied); err != nil {
t.Errorf("count migrations: %v", err)
continue
}
if applied == 0 {
t.Error("no migration was recorded")
}
}
}
func mustOpen(t *testing.T, databaseURL string) *sql.DB {
t.Helper()
db, err := database.Open(context.Background(), databaseURL)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}

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
}

159
internal/store/profile.go Normal file
View file

@ -0,0 +1,159 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
// Membership is one tenant role held by a user.
type Membership struct {
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
TenantName string `json:"tenant_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Permissions []string `json:"permissions"`
}
// Profile is a user's own account information. AvatarKey is the stored locator;
// the server expands it into an absolute AvatarURL.
type Profile struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
Memberships []Membership `json:"memberships"`
IsSystemAdministrator bool `json:"is_system_administrator"`
NameCustomized bool `json:"name_customized"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at"`
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
}
// ProfileUpdate carries a self-service profile change. A nil DisplayName leaves
// the display name untouched. Avatar changes are mutually exclusive: Remove
// clears the picture, otherwise Data and ContentType replace it.
type ProfileUpdate struct {
DisplayName *string
RemoveAvatar bool
AvatarKey string
AvatarData []byte
ContentType string
}
// Picture is a stored profile picture.
type Picture struct {
Data []byte
ContentType string
UpdatedAt time.Time
}
// Profile loads a user together with every tenant role they hold.
func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
var profile Profile
var avatarKey sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT u.id, u.email, u.display_name, u.avatar_key, u.name_customized,
u.created_at, u.last_login_at, u.profile_updated_at, u.avatar_updated_at,
EXISTS(SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id)
FROM users u
WHERE u.id = $1`, userID).
Scan(&profile.ID, &profile.Email, &profile.DisplayName, &avatarKey, &profile.NameCustomized,
&profile.CreatedAt, &profile.LastLoginAt, &profile.ProfileUpdatedAt,
&profile.AvatarUpdatedAt, &profile.IsSystemAdministrator)
if errors.Is(err, sql.ErrNoRows) {
return Profile{}, ErrNotFound
}
if err != nil {
return Profile{}, err
}
if avatarKey.Valid {
profile.AvatarKey = &avatarKey.String
}
profile.Memberships = []Membership{}
rows, err := s.db.QueryContext(ctx, `
SELECT m.tenant_id, t.slug, t.name, r.slug, m.role_id,
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id
), '[]'::json)
FROM memberships m
JOIN tenants t ON t.id = m.tenant_id
JOIN roles r ON r.id = m.role_id
WHERE m.user_id = $1
ORDER BY t.name`, userID)
if err != nil {
return Profile{}, err
}
defer rows.Close()
for rows.Next() {
var item Membership
var permissions []byte
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role,
&item.RoleID, &permissions); err != nil {
return Profile{}, err
}
if err := json.Unmarshal(permissions, &item.Permissions); err != nil {
return Profile{}, err
}
profile.Memberships = append(profile.Memberships, item)
}
return profile, rows.Err()
}
// UpdateProfile applies a self-service profile change. The user is identified by
// their own id, so no membership check is required: every account may edit its
// own display name and picture regardless of role.
func (s *Store) UpdateProfile(ctx context.Context, userID string, update ProfileUpdate) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if update.DisplayName != nil {
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET display_name = $2, name_customized = true, profile_updated_at = now()
WHERE id = $1`, userID, *update.DisplayName); err != nil {
return err
}
}
switch {
case update.RemoveAvatar:
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET avatar = NULL, avatar_key = NULL, avatar_content_type = NULL,
avatar_updated_at = NULL, profile_updated_at = now()
WHERE id = $1`, userID); err != nil {
return err
}
case len(update.AvatarData) > 0:
if _, err := tx.ExecContext(ctx, `
UPDATE users
SET avatar = $2, avatar_key = $3, avatar_content_type = $4,
avatar_updated_at = now(), profile_updated_at = now()
WHERE id = $1`, userID, update.AvatarData, update.AvatarKey, update.ContentType); err != nil {
return err
}
}
return tx.Commit()
}
// Picture loads a stored profile picture by its unguessable key.
func (s *Store) Picture(ctx context.Context, key string) (Picture, error) {
var picture Picture
err := s.db.QueryRowContext(ctx, `
SELECT avatar, avatar_content_type, avatar_updated_at
FROM users
WHERE avatar_key = $1 AND avatar IS NOT NULL`, key).
Scan(&picture.Data, &picture.ContentType, &picture.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Picture{}, ErrNotFound
}
return picture, err
}

505
internal/store/roles.go Normal file
View file

@ -0,0 +1,505 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/danyel/go-loose/internal/role"
)
// Role is a named bundle of permissions. A role with no TenantID is built in:
// every tenant sees it and it cannot be edited or deleted. A role with a
// TenantID belongs to that tenant alone.
type Role struct {
ID string `json:"id"`
TenantID *string `json:"tenant_id,omitempty"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
System bool `json:"system"`
// Permissions is sorted so that the console can compare two roles without
// worrying about the order they were stored in.
Permissions []string `json:"permissions"`
MemberCount int `json:"member_count"`
// Assignable reports whether the requesting administrator may hand this role
// to somebody. A role is assignable only when every permission it grants is
// one the administrator already holds, so managing members can never be used
// to grant more authority than the administrator has.
Assignable bool `json:"assignable"`
}
var (
// ErrRoleInUse is returned when a role that still has members is deleted.
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 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")
)
// querier is satisfied by both *sql.DB and *sql.Tx so that the authorization
// helpers can run inside or outside a transaction.
type querier interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// scanner is satisfied by both *sql.Row and *sql.Rows.
type scanner interface {
Scan(dest ...any) error
}
// permissionPredicate renders the one authorization check used by every
// tenant-scoped statement: it holds when the user holds permission in the
// tenant. The arguments are SQL expressions rather than placeholders because the
// queries that need it have already joined memberships under a name of their
// own. Resolving the grant through role_permissions is what lets a tenant define
// its own roles without any Go code having to know they exist.
func permissionPredicate(tenantExpr, userExpr, permissionExpr string) string {
return fmt.Sprintf(`EXISTS (
SELECT 1
FROM memberships scoped
JOIN role_permissions granted ON granted.role_id = scoped.role_id
WHERE scoped.tenant_id = %s AND scoped.user_id = %s
AND granted.permission = %s
)`, tenantExpr, userExpr, permissionExpr)
}
// SystemRoleID returns the id of a built-in role. Callers that seed memberships
// outside a tenant context -- the installer, the bootstrapper -- need it because
// a membership is a role id rather than a name.
func (s *Store) SystemRoleID(ctx context.Context, slug string) (string, error) {
var id string
err := s.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = $1 AND tenant_id IS NULL`, slug).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
// clientPermissions is the permission aggregate reported to client applications
// through /connect/userinfo. A user with no membership in the owning tenant is
// described by the built-in User role, which is the least privileged one.
const clientPermissions = `COALESCE(
(SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id),
(SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp
JOIN roles fallback ON fallback.id = rp.role_id
WHERE fallback.slug = 'user' AND fallback.tenant_id IS NULL),
'[]'::json)`
// heldPermissions returns every permission the actor holds in a tenant, sorted.
// It is the basis for both the per-action checks and the escalation guard: an
// actor may never hand out a capability they do not have themselves.
func heldPermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
rows, err := q.QueryContext(ctx, `
SELECT DISTINCT granted.permission
FROM memberships scoped
JOIN role_permissions granted ON granted.role_id = scoped.role_id
WHERE scoped.tenant_id = $1 AND scoped.user_id = $2
ORDER BY granted.permission`, tenantID, actorID)
if err != nil {
return nil, err
}
defer rows.Close()
held := make([]string, 0)
for rows.Next() {
var permission string
if err := rows.Scan(&permission); err != nil {
return nil, err
}
held = append(held, permission)
}
return held, rows.Err()
}
// hasPermission reports whether the actor holds one permission in a tenant.
func hasPermission(ctx context.Context, q querier, actorID, tenantID string, permission role.Permission) (bool, error) {
var permitted bool
err := q.QueryRowContext(ctx,
`SELECT `+permissionPredicate("$2", "$1", "$3"),
actorID, tenantID, string(permission)).Scan(&permitted)
return permitted, err
}
// within reports whether every permission in granted also appears in held.
func within(granted, held []string) bool {
available := make(map[string]bool, len(held))
for _, permission := range held {
available[permission] = true
}
for _, permission := range granted {
if !available[permission] {
return false
}
}
return true
}
// 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 {
name := strings.TrimSpace(value)
if name == "" {
continue
}
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 rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
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.
func roleGrants(ctx context.Context, q querier, roleID string) ([]string, error) {
rows, err := q.QueryContext(ctx, `
SELECT permission FROM role_permissions WHERE role_id = $1 ORDER BY permission`, roleID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0)
for rows.Next() {
var permission string
if err := rows.Scan(&permission); err != nil {
return nil, err
}
result = append(result, permission)
}
return result, rows.Err()
}
func scanRole(row scanner) (Role, error) {
var item Role
var tenantID sql.NullString
if err := row.Scan(&item.ID, &tenantID, &item.Slug, &item.Name, &item.Description,
&item.System, &item.MemberCount); err != nil {
return Role{}, err
}
if tenantID.Valid {
item.TenantID = &tenantID.String
}
return item, nil
}
const roleSelect = `
SELECT r.id, r.tenant_id, r.slug, r.name, r.description, r.is_system,
(SELECT count(*) FROM memberships m WHERE m.role_id = r.id)
FROM roles r `
// ListRoles returns the roles available in a tenant: every built-in role plus
// that tenant's own. Reading the catalog needs users.read so the role screen can
// describe a role before anybody is assigned it.
func (s *Store) ListRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) {
permitted, err := hasPermission(ctx, s.db, actorID, tenantID, role.ReadUsers)
if err != nil {
return nil, err
}
if !permitted {
return nil, ErrNotFound
}
roles, err := s.rolesIn(ctx, s.db, actorID, tenantID)
if err != nil {
return nil, err
}
return roles, nil
}
// AssignableRoles returns only the roles the administrator may hand to a member.
func (s *Store) AssignableRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) {
roles, err := s.ListRoles(ctx, actorID, tenantID)
if err != nil {
return nil, err
}
result := make([]Role, 0, len(roles))
for _, item := range roles {
if item.Assignable {
result = append(result, item)
}
}
return result, nil
}
// rolesIn loads the tenant's catalog and marks what the actor may assign.
func (s *Store) rolesIn(ctx context.Context, q querier, actorID, tenantID string) ([]Role, error) {
rows, err := q.QueryContext(ctx, roleSelect+`
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
ORDER BY r.is_system DESC,
array_position(ARRAY['owner','admin','developer','operator','viewer','user'], r.slug) NULLS LAST,
r.name`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
held, err := heldPermissions(ctx, q, actorID, tenantID)
if err != nil {
return nil, err
}
result := make([]Role, 0)
for rows.Next() {
item, err := scanRole(rows)
if err != nil {
return nil, err
}
permissions, err := roleGrants(ctx, q, item.ID)
if err != nil {
return nil, err
}
item.Permissions = permissions
item.Assignable = within(permissions, held)
result = append(result, item)
}
return result, rows.Err()
}
func roleByID(ctx context.Context, q querier, roleID string) (Role, error) {
item, err := scanRole(q.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1`, roleID))
if errors.Is(err, sql.ErrNoRows) {
return Role{}, ErrNotFound
}
if err != nil {
return Role{}, err
}
return item, nil
}
// 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) {
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
}
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)
if err != nil {
return Role{}, err
}
for _, permission := range granted {
if _, err := tx.ExecContext(ctx, `
INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`,
created.ID, permission); err != nil {
return Role{}, err
}
}
created.Permissions = granted
created.Assignable = true
return created, tx.Commit()
}
// 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) {
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 {
return Role{}, err
}
if existing.System || existing.TenantID == nil {
return Role{}, ErrNotFound
}
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
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, `
UPDATE roles SET name = $2, description = $3, updated_at = now() WHERE id = $1`,
roleID, name, description); err != nil {
return Role{}, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = $1`, roleID); err != nil {
return Role{}, err
}
for _, permission := range granted {
if _, err := tx.ExecContext(ctx, `
INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`,
roleID, permission); err != nil {
return Role{}, err
}
}
existing.Name, existing.Description, existing.Permissions = name, description, granted
return existing, tx.Commit()
}
// DeleteRole removes a tenant's own role. Built-in roles and roles that still
// have members cannot be deleted, so nobody is left pointing at nothing.
func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
existing, err := lockRole(ctx, tx, roleID)
if err != nil {
return err
}
if existing.System || existing.TenantID == nil {
return ErrNotFound
}
permissions, err := roleGrants(ctx, tx, roleID)
if err != nil {
return err
}
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return err
}
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 {
return ErrRoleInUse
}
if _, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID); err != nil {
return err
}
return tx.Commit()
}
// resolveRoleID validates that a role may be assigned to a member of the tenant
// and returns its id. A role belonging to another tenant is reported as not
// found so that role ids cannot be probed across tenants.
func resolveRoleID(ctx context.Context, tx *sql.Tx, tenantID, roleID string, held []string) (string, error) {
item, err := roleByID(ctx, tx, roleID)
if err != nil {
return "", err
}
if item.TenantID != nil && *item.TenantID != tenantID {
return "", ErrNotFound
}
granted, err := roleGrants(ctx, tx, roleID)
if err != nil {
return "", err
}
if !within(granted, held) {
return "", ErrNotFound
}
return roleID, nil
}
// lockRole loads a role and holds the row for the rest of the transaction.
func lockRole(ctx context.Context, tx *sql.Tx, roleID string) (Role, error) {
item, err := scanRole(tx.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1 FOR UPDATE`, roleID))
if errors.Is(err, sql.ErrNoRows) {
return Role{}, ErrNotFound
}
if err != nil {
return Role{}, err
}
permissions, err := roleGrants(ctx, tx, roleID)
if err != nil {
return Role{}, err
}
item.Permissions = permissions
return item, nil
}
// insertRole writes the role row, translating a slug collision into a stable
// error rather than a driver-specific constraint violation.
func insertRole(ctx context.Context, tx *sql.Tx, tenantID, slug, name, description string) (Role, error) {
item, err := scanRole(tx.QueryRowContext(ctx, `
INSERT INTO roles(tenant_id, slug, name, description)
VALUES ($1, $2, $3, $4)
RETURNING id, tenant_id, slug, name, description, is_system, 0`,
tenantID, slug, name, description))
if err != nil {
if isUniqueViolation(err) {
return Role{}, ErrRoleSlugTaken
}
return Role{}, err
}
return item, nil
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "duplicate key value")
}

View file

@ -0,0 +1,242 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// rolesFixture creates a tenant owned by owner plus a developer member. The
// developer is the interesting actor: it administers members but does not hold
// roles.manage, so it is the boundary the escalation guards are tested against.
func rolesFixture(t *testing.T) (s *store.Store, ctx context.Context, owner, developer, tenantID string) {
t.Helper()
s, _, ctx = integrationStore(t)
owner = newUser(t, s, ctx, "owner@example.test")
tenantID = newTenant(t, s, ctx, owner, "nmbs").ID
developer = addMember(t, s, ctx, owner, tenantID, "developer@example.test", "developer")
return s, ctx, owner, developer, tenantID
}
func systemRole(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 built-in role %q: %v", slug, err)
}
return id
}
func TestTenantCanDefineItsOwnRoleAndItIsEnforced(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenantID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
created, err := s.CreateRole(ctx, owner, tenantID, "release-manager",
"Release manager", "Prepares releases.",
[]string{string(role.ReadConsole), string(role.ManageContracts), string(role.ReadUsers)})
if err != nil {
t.Fatalf("create role: %v", err)
}
if created.System {
t.Error("a tenant role must not be marked as built in")
}
if created.TenantID == nil || *created.TenantID != tenantID {
t.Errorf("tenant id = %v, want %s", created.TenantID, tenantID)
}
if !contains(created.Permissions, string(role.ManageContracts)) {
t.Errorf("permissions = %v, want contracts.manage", created.Permissions)
}
// The role grants exactly what it says and nothing more.
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("assign the custom role: %v", err)
}
_, err = s.CreateAPIKey(ctx, member, application.ID, "key", "gl_prefix", secretHash("custom-denied"), nil)
assertPermission(t, "custom role issuing a key", err, false)
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
assertPermission(t, "custom role importing a contract", err, true)
}
func TestCustomRoleIsScopedToItsOwnTenant(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
other := newUser(t, s, ctx, "other-owner@example.test")
otherTenant := newTenant(t, s, ctx, other, "ypto").ID
created, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", []string{string(role.ReadConsole)})
if err != nil {
t.Fatalf("create role: %v", err)
}
roles, err := s.ListRoles(ctx, other, otherTenant)
if err != nil {
t.Fatalf("list roles: %v", err)
}
for _, item := range roles {
if item.ID == created.ID {
t.Fatal("a role leaked across tenants")
}
}
if err := s.DeleteRole(ctx, other, created.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant delete = %v, want ErrNotFound", err)
}
if _, err := s.UpdateRole(ctx, other, created.ID, "Hijacked", "", nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant update = %v, want ErrNotFound", err)
}
}
func TestBuiltInRolesCannotBeEditedOrDeleted(t *testing.T) {
s, ctx, owner, _, _ := rolesFixture(t)
ownerRole := systemRole(t, s, ctx, "owner")
if _, err := s.UpdateRole(ctx, owner, ownerRole, "Supreme", "", nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("editing a built-in role = %v, want ErrNotFound", err)
}
if err := s.DeleteRole(ctx, owner, ownerRole); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("deleting a built-in role = %v, want ErrNotFound", err)
}
}
func TestRoleInUseCannotBeDeleted(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
created, err := s.CreateRole(ctx, owner, tenantID, "support", "Support", "", []string{string(role.ReadConsole)})
if err != nil {
t.Fatalf("create role: %v", err)
}
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("assign role: %v", err)
}
if err := s.DeleteRole(ctx, owner, created.ID); !errors.Is(err, store.ErrRoleInUse) {
t.Fatalf("delete an assigned role = %v, want ErrRoleInUse", err)
}
// Once nobody holds it the delete goes through.
if err := s.SetUserAccess(ctx, owner, tenantID, member, systemRole(t, s, ctx, "viewer"), nil); err != nil {
t.Fatalf("move the member off the role: %v", err)
}
if err := s.DeleteRole(ctx, owner, created.ID); err != nil {
t.Fatalf("delete an unused role: %v", err)
}
}
// TestRoleAssignmentCannotEscalate is the guard that matters most now that
// developer and operator may manage members: they must not be able to hand
// somebody a role more powerful than their own, which would otherwise be a
// straightforward route to owner.
func TestRoleAssignmentCannotEscalate(t *testing.T) {
s, ctx, owner, developer, tenantID := rolesFixture(t)
victim := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
ownerRole := systemRole(t, s, ctx, "owner")
if err := s.SetUserAccess(ctx, developer, tenantID, victim, ownerRole, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer promoting a member to owner = %v, want ErrNotFound", err)
}
if _, err := s.InviteUser(ctx, developer, tenantID, "sneaky@example.test", "Sneaky", ownerRole, "hash"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer inviting as owner = %v, want ErrNotFound", err)
}
if _, err := s.CreateRole(ctx, developer, tenantID, "shadow-owner", "Shadow", "",
[]string{string(role.ReadConsole), string(role.ManageTenants)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating an owner-grade role = %v, want ErrNotFound", err)
}
// Nor may they define any role at all: roles.manage is not theirs to hold,
// however weak the role they were trying to mint.
if _, err := s.CreateRole(ctx, developer, tenantID, "teammate", "Teammate", "",
[]string{string(role.ReadConsole), string(role.ReadUsers)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating any role = %v, want ErrNotFound", err)
}
// The developer can still maintain members using the roles already offered.
if _, err := s.InviteUser(ctx, developer, tenantID, "teammate@example.test", "Teammate",
systemRole(t, s, ctx, "viewer"), "hash"); err != nil {
t.Fatalf("developer inviting a member: %v", err)
}
}
func TestRolesManageIsRequiredToDefineRoles(t *testing.T) {
s, ctx, _, developer, tenantID := rolesFixture(t)
if _, err := s.ListRoles(ctx, developer, tenantID); err != nil {
t.Fatalf("a developer may read the role catalog: %v", err)
}
if _, err := s.CreateRole(ctx, developer, tenantID, "nope", "Nope", "",
[]string{string(role.ReadConsole)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating a role = %v, want ErrNotFound", err)
}
}
func TestAssignableRolesExcludeAnythingAboveTheActor(t *testing.T) {
s, ctx, _, developer, tenantID := rolesFixture(t)
assignable, err := s.AssignableRoles(ctx, developer, tenantID)
if err != nil {
t.Fatalf("assignable roles: %v", err)
}
ownerRole := systemRole(t, s, ctx, "owner")
found := 0
for _, item := range assignable {
if !item.Assignable {
t.Errorf("role %q was returned but is not marked assignable", item.Slug)
}
if contains(item.Permissions, string(role.ManageTenants)) {
t.Errorf("developer may assign %q, which grants tenants.manage", item.Slug)
}
if item.ID == ownerRole {
t.Error("a developer must not be offered the owner role")
}
found++
}
if found == 0 {
t.Fatal("a developer should still be offered the roles within their authority")
}
}
func TestUnknownPermissionIsRejectedWhenDefiningARole(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
[]string{string(role.ReadConsole), "superpower"})
if err == nil {
t.Fatal("a role must not be creatable with an unknown permission")
}
}
func TestDuplicateRoleSlugIsRejected(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
if _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "",
[]string{string(role.ReadConsole)}); err != nil {
t.Fatalf("create role: %v", err)
}
_, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Another", "",
[]string{string(role.ReadConsole)})
if !errors.Is(err, store.ErrRoleSlugTaken) {
t.Fatalf("duplicate slug = %v, want ErrRoleSlugTaken", err)
}
}
// TestTenantKeepsAnOwner guards the demotion path: the last owner of a tenant
// must not be able to walk away from it and lock every administrator out.
func TestTenantKeepsAnOwner(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
admin := addMember(t, s, ctx, owner, tenantID, "admin@example.test", "admin")
adminRole := systemRole(t, s, ctx, "admin")
ownerRole := systemRole(t, s, ctx, "owner")
// The owner cannot simply step down while nobody else holds the role.
if err := s.SetUserAccess(ctx, owner, tenantID, owner, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
t.Fatalf("owner stepping down alone = %v, want ErrLastOwner", err)
}
// Handing the role over first makes the step down legal.
if err := s.SetUserAccess(ctx, owner, tenantID, admin, ownerRole, nil); err != nil {
t.Fatalf("promote the admin to owner: %v", err)
}
if err := s.SetUserAccess(ctx, admin, tenantID, owner, adminRole, nil); err != nil {
t.Fatalf("step down once a second owner exists: %v", err)
}
// The admin is now the only owner and must not be able to walk away from it.
if err := s.SetUserAccess(ctx, admin, tenantID, admin, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
t.Fatalf("demoting the last owner = %v, want ErrLastOwner", err)
}
}

View file

@ -10,10 +10,14 @@ import (
"time"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/role"
)
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
}
@ -29,6 +33,9 @@ type Tenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
Role string `json:"role"`
// Permissions is read from the role's own rows rather than from a Go matrix,
// so a tenant that defines its own roles is described accurately.
Permissions []string `json:"permissions"`
}
type Application struct {
@ -86,8 +93,15 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri
return User{}, err
}
defer tx.Rollback()
// The identity provider owns the display name until the user customizes it
// in their profile. The row must still match on every login, otherwise the
// insert below would collide with the unique email index.
err = tx.QueryRowContext(ctx, `
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
UPDATE users
SET subject = $1,
email = lower($2),
display_name = CASE WHEN name_customized THEN display_name ELSE $3 END,
last_login_at = now()
WHERE lower(email) = lower($2)
RETURNING id, email, display_name`, subject, email, displayName).
Scan(&user.ID, &user.Email, &user.DisplayName)
@ -118,12 +132,22 @@ 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)
SELECT $1, $2, 'owner'
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
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 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
}
if _, err = tx.ExecContext(ctx, `
@ -146,9 +170,15 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT t.id, t.slug, t.name, m.role
SELECT t.id, t.slug, t.name, r.slug,
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp
WHERE rp.role_id = m.role_id
), '[]'::json)
FROM tenants t
JOIN memberships m ON m.tenant_id = t.id
JOIN roles r ON r.id = m.role_id
WHERE m.user_id = $1
ORDER BY t.name`, userID)
if err != nil {
@ -158,7 +188,11 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error
result := make([]Tenant, 0)
for rows.Next() {
var item Tenant
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
var permissions []byte
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role, &permissions); err != nil {
return nil, err
}
if err := json.Unmarshal(permissions, &item.Permissions); err != nil {
return nil, err
}
result = append(result, item)
@ -175,13 +209,34 @@ 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
}
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
// Whoever creates a tenant owns it.
var permissions []byte
err = tx.QueryRowContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, $2, r.id FROM roles r
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
RETURNING (SELECT slug FROM roles WHERE slug = 'owner' AND tenant_id IS NULL),
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp
JOIN roles granted ON granted.id = rp.role_id
WHERE granted.slug = 'owner' AND granted.tenant_id IS NULL
), '[]'::json)`,
tenant.ID, userID).Scan(&tenant.Role, &permissions)
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal(permissions, &tenant.Permissions); err != nil {
return Tenant{}, err
}
tenant.Role = "owner"
if err := tx.Commit(); err != nil {
return Tenant{}, err
}
@ -224,12 +279,11 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic
row := s.db.QueryRowContext(ctx, `
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (
SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin')
)
WHERE `+permissionPredicate("$1", "$6", "$7")+`
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
client_id, to_json(redirect_uris), false`,
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID)
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID,
string(role.ManageApplications))
var allowedHosts, redirectURIs []byte
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
@ -272,10 +326,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin')
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$6", "$7")+`
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
applicationID, name, prefix, hash, expiresAt, userID).
applicationID, name, prefix, hash, expiresAt, userID, string(role.ManageKeys)).
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound
@ -286,9 +339,8 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
FROM memberships m
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`,
keyID, userID)
WHERE k.id = $1 AND `+permissionPredicate("k.tenant_id", "$2", "$3"),
keyID, userID, string(role.ManageKeys))
if err != nil {
return err
}
@ -348,10 +400,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
SELECT a.id, $2, $3, $4, $5
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin')
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$5", "$6")+`
RETURNING id, application_id, version, source_url, created_at`,
applicationID, document.Version, sourceURL, document.JSON, userID).
applicationID, document.Version, sourceURL, document.JSON, userID,
string(role.ManageContracts)).
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Contract{}, ErrNotFound

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

@ -1,5 +1,12 @@
const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''};
const state = {
tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [],
roles: [], permissions: [], profile: null, selectedTenant: ''
};
// Capability names mirror internal/role. The server sends the grants for the
// selected tenant so the console never re-implements the matrix.
const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission));
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
@ -38,14 +45,46 @@ async function load() {
const data = await api('/api/v1/dashboard');
Object.assign(state, data);
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
$('#identity').textContent = data.user.display_name || data.user.email;
await loadRoles();
render();
}
// loadRoles fetches the role catalog for the selected tenant. Roles are rows now
// rather than a fixed list, so they have to be asked for per tenant, and the
// server marks which ones the signed-in user is allowed to hand out.
async function loadRoles() {
if (!state.selectedTenant) {
state.roles = [];
return
}
try {
state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles
} catch (error) {
// A user who cannot read the role catalog still gets the rest of the
// console; the invite and access dialogs simply offer nothing.
state.roles = [];
}
}
// 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
// anything else would only produce an error.
function assignableRoles() {
return state.roles.filter(item => item.assignable)
}
function roleLabel(roleID) {
return state.roles.find(item => item.id === roleID)?.name || roleID
}
function render() {
const tenantID = state.selectedTenant;
const selectedTenant = state.tenants.find(item => item.id === tenantID);
const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role);
const canManageApplications = grants(selectedTenant, 'applications.manage');
const canManageKeys = grants(selectedTenant, 'keys.manage');
const canManageContracts = grants(selectedTenant, 'contracts.manage');
const canManageUsers = grants(selectedTenant, 'users.manage');
const tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
const tenantAppIDs = new Set(tenantApps.map(item => item.id));
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id));
@ -59,13 +98,17 @@ 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>${canManageTenant ? `<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>${canManageTenant && 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><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(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageTenant ? `<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');
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant);
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);
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
$$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers);
$$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
select.value = tenantID;
@ -73,7 +116,31 @@ function render() {
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''));
}
// renderRoleOptions fills both role pickers. The current value is preserved so
// that re-rendering the table does not silently reset a pending edit.
function renderRoleOptions() {
const options = assignableRoles();
const html = options.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
for (const id of ['#invite-role', '#access-role']) {
const select = $(id);
if (!select) continue;
const current = select.value;
select.innerHTML = html;
if (current && options.some(item => item.id === current)) select.value = current
}
}
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">`
: `<span class="avatar avatar-empty">${escapeHTML(initials(item.display_name || item.email))}</span>`;
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
function formJSON(form) {
@ -189,7 +256,10 @@ document.addEventListener('click', async event => {
form.tenant_id.value = user.tenant_id;
form.approval_mode.value = 'false';
$('#approval-tenant-label').hidden = true;
form.role.value = user.role;
// A role the caller may not hand out is not in the picker, so fall back
// to the first one they can assign rather than submitting the old value.
const current = assignableRoles().some(item => item.id === user.role_id);
form.role_id.value = current ? user.role_id : assignableRoles()[0]?.id || '';
renderAccessApps(user.tenant_id, user.application_ids);
$('#access-dialog').showModal();
return
@ -201,7 +271,7 @@ document.addEventListener('click', async event => {
form.approval_mode.value = 'true';
$('#approval-tenant-label').hidden = false;
form.approval_tenant_id.value = state.selectedTenant;
form.role.value = 'viewer';
form.role_id.value = assignableRoles()[0]?.id || '';
renderAccessApps(state.selectedTenant, []);
$('#access-dialog').showModal()
}
@ -215,21 +285,19 @@ 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)
});
$('#tenant-context').addEventListener('change', event => {
// 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();
render()
});
$('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, []));

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>
@ -13,13 +19,15 @@
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav>
<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>
<span id="identity" class="identity"></span>
<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>
@ -58,15 +66,15 @@
<div class="panels">
<section class="panel active" id="applications">
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-action>New application</button></div>
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-application-action>New application</button></div>
<div class="card-grid" id="app-list"></div>
</section>
<section class="panel" id="keys">
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-action>Issue key</button></div>
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-key-action>Issue key</button></div>
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div>
</section>
<section class="panel" id="contracts">
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-action>Import contract</button></div>
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-contract-action>Import contract</button></div>
<div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div>
</section>
<section class="panel" id="tenants">
@ -74,8 +82,9 @@
<div class="card-grid" id="tenant-list"></div>
</section>
<section class="panel" id="users">
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-action>Invite user</button></div>
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-user-action>Invite user</button></div>
<div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div>
<p class="muted">Roles are per tenant and grant capabilities. <a href="/roles">Manage roles</a> to review or define your own. <a href="/profile">Open your own profile</a> to change your name and picture.</p>
</section>
</div>
</section>
@ -86,10 +95,16 @@
<dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog>
<dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0&#10;paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog>
<dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label><button type="submit">Invite user</button></form></dialog>
<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"><option value="viewer">Viewer</option><option value="admin">Administrator</option><option value="owner">Owner</option></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="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role_id" id="invite-role" required></select></label><p class="muted">You can only assign roles that are no more powerful than your own.</p><button type="submit">Invite user</button></form></dialog>
<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

@ -4,7 +4,7 @@
<body class="login-page">
<div class="login-glow"></div>
<main class="terminal">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@access-plane:~</strong></div>
<div class="terminal-bar"><span></span><span></span><span></span><strong>{{LOGIN_PROMPT}}</strong></div>
<div class="terminal-body">
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
@ -13,7 +13,7 @@
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<p class="eyebrow">{{LOGIN_CONTEXT}}</p>
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p>
<p><span class="prompt">{{LOGIN_PROMPT}}$</span> initialize secure session</p>
<p class="muted">{{LOGIN_MESSAGE}}</p>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
@ -23,7 +23,7 @@
</form>
<div style="{{SSO_STYLE}}">
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p>
<p class="cursor-line"><span class="prompt">{{LOGIN_PROMPT}}$</span> <span class="cursor">█</span></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

@ -5,14 +5,7 @@
"version": "0.1.0",
"description": "Multi-tenant API-key management and runtime authorization."
},
"servers": [{"url": "/"}],
"components": {
"securitySchemes": {
"session": {"type": "apiKey", "in": "cookie", "name": "go_loose_session"},
"apiKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"},
"clientBasic": {"type": "http", "scheme": "basic"},
"userBearer": {"type": "http", "scheme": "bearer"}
},
"schemas": {
"Error": {"type": "object", "properties": {"error": {"type": "string"}}},
"AuthorizationRequest": {
@ -38,6 +31,99 @@
"api_key_id": {"type": "string", "format": "uuid"},
"api_key_name": {"type": "string"}
}
},
"Membership": {
"type": "object",
"description": "A tenant role held by a user, with the capabilities it grants.",
"properties": {
"tenant_id": {"type": "string", "format": "uuid"},
"tenant_slug": {"type": "string"},
"tenant_name": {"type": "string"},
"role": {"type": "string", "description": "Slug of the role, which may be a role this tenant defined."},
"role_id": {"type": "string", "format": "uuid", "description": "Identifier of the role row. Send this back when assigning the role."},
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}}
}
},
"Profile": {
"type": "object",
"properties": {
"id": {"type": "string", "format": "uuid"},
"email": {"type": "string", "format": "email"},
"display_name": {"type": "string"},
"avatar_url": {"type": "string", "description": "Absolute picture URL, or an empty string when no picture is set."},
"memberships": {"type": "array", "items": {"$ref": "#/components/schemas/Membership"}},
"is_system_administrator": {"type": "boolean"},
"name_customized": {"type": "boolean", "description": "True once the user set the display name themselves, which stops the identity provider from overwriting it."},
"created_at": {"type": "string", "format": "date-time"},
"last_login_at": {"type": "string", "format": "date-time"},
"profile_updated_at": {"type": "string", "format": "date-time"},
"avatar_updated_at": {"type": "string", "format": "date-time"}
}
},
"ProfileUpdate": {
"type": "object",
"description": "Send display_name, avatar, or remove_avatar. avatar accepts raw base64 or a data URL.",
"properties": {
"display_name": {"type": "string", "minLength": 1, "maxLength": 200},
"avatar": {"type": "string", "description": "Base64 or data URL of a JPEG, PNG, or GIF up to 2 MiB and 4096 by 4096 pixels."},
"remove_avatar": {"type": "boolean"}
}
},
"Role": {
"type": "object",
"description": "A named bundle of permissions. A built-in role has no tenant_id, is visible to every tenant, and cannot be edited or deleted. A tenant may define its own.",
"required": ["id", "slug", "name", "system", "permissions"],
"properties": {
"id": {"type": "string", "format": "uuid"},
"tenant_id": {"type": "string", "format": "uuid", "description": "Absent for built-in roles."},
"slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"},
"name": {"type": "string", "minLength": 1, "maxLength": 100},
"description": {"type": "string", "maxLength": 500},
"system": {"type": "boolean", "description": "True for built-in roles, which are read only."},
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}},
"member_count": {"type": "integer", "description": "Members currently holding the role. A role that still has members cannot be deleted."},
"assignable": {"type": "boolean", "description": "Whether the caller may assign this role. False when the role grants a permission the caller does not hold, which is what stops role assignment becoming a way to gain authority."}
}
},
"PermissionValue": {
"type": "string",
"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
},
"Permission": {
"type": "object",
"description": "An entry in the installation-wide permission catalog.",
"required": ["name", "label", "enforced"],
"properties": {
"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": {
"type": "object",
"required": ["name", "permissions"],
"properties": {
"tenant_id": {"type": "string", "format": "uuid", "description": "Required when creating. Ignored when updating, which takes the role's own tenant."},
"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"},
"description": "Names from the permission catalog. An unknown name is rejected with 400."}
}
}
}
},
@ -93,10 +179,54 @@
"Dashboard Management"
],
"summary": "Get management console data",
"description": "Requires a role that grants console.read. Each tenant and user entry carries the capability names its role grants.",
"security": [{"session": []}],
"responses": {"200": {"description": "Dashboard data"}}
}
},
"/profile": {
"get": {
"tags": ["Profile"],
"summary": "Open the self-service profile screen",
"description": "Available to every authenticated management session, including self-service User members.",
"security": [{"session": []}],
"responses": {"200": {"description": "Profile screen"}, "303": {"description": "Redirect to login"}}
}
},
"/api/v1/profile": {
"get": {
"tags": ["Profile"],
"summary": "Read the caller's own profile and tenant roles",
"security": [{"session": []}],
"responses": {
"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
"401": {"description": "Authentication required"}
}
},
"put": {
"tags": ["Profile"],
"summary": "Update the caller's display name or profile picture",
"security": [{"session": []}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdate"}}}},
"responses": {
"200": {"description": "Updated profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
"400": {"description": "Invalid display name or unsupported picture"}
}
}
},
"/api/v1/avatars/{key}": {
"get": {
"tags": ["Profile"],
"summary": "Fetch a profile picture",
"description": "Public on purpose so other applications can embed the picture with a plain image tag. The key is 256 bits of randomness and exposes nothing about the user.",
"parameters": [{"in": "path", "name": "key", "required": true, "schema": {"type": "string"}}],
"responses": {
"200": {"description": "Image bytes", "content": {"image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/png": {"schema": {"type": "string", "format": "binary"}}, "image/gif": {"schema": {"type": "string", "format": "binary"}}}},
"304": {"description": "Not modified"},
"404": {"description": "No picture for that key"}
}
}
},
"/api/v1/tenants": {
"post": {
"tags": [
@ -169,6 +299,7 @@
"User Action"
],
"summary": "Exchange a one-time authorization code",
"description": "The nested user object carries role, permissions, and avatar_url for the tenant owning the application.",
"security": [{"clientBasic": []}],
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}},
"responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}}
@ -179,7 +310,8 @@
"tags": [
"User Action"
],
"summary": "Validate a client user session",
"summary": "Validate a client user session and read the caller's role and picture",
"description": "Returns id, email, display_name, avatar_url, tenant and application identity, role, and permissions.",
"security": [{"userBearer": []}],
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
}
@ -210,7 +342,8 @@
"tags": [
"User Management"
],
"summary": "Invite a tenant user",
"summary": "Invite a tenant user with a role",
"description": "Requires users.manage in the tenant, which owner, admin, developer, and operator hold. role_id must name a role of that tenant whose permissions are a subset of the caller's own.",
"security": [{"session": []}],
"responses": {"201": {"description": "User invited"}}
}
@ -221,16 +354,87 @@
"User Management"
],
"summary": "Set tenant role and application access",
"description": "Requires users.manage. The caller may not grant a role more powerful than their own, and may not demote the last owner of a tenant.",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "Access updated"}}
}
},
"/api/v1/roles": {
"get": {
"tags": ["Role Management"],
"summary": "List the roles available in a tenant",
"description": "Returns every built-in role plus the roles this tenant defined, each annotated with whether the caller may assign it.",
"security": [{"session": []}],
"parameters": [{"in": "query", "name": "tenant_id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "object", "properties": {"roles": {"type": "array", "items": {"$ref": "#/components/schemas/Role"}}}}}}}, "404": {"description": "Not a member of that tenant"}}
},
"post": {
"tags": ["Role Management"],
"summary": "Define a role for a tenant",
"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"}}
}
},
"/api/v1/roles/{id}": {
"put": {
"tags": ["Role Management"],
"summary": "Rename a role and replace its permissions",
"description": "Built-in roles are immutable. Saving replaces the permissions of every member holding the role, so treat it as a live change.",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleRequest"}}}},
"responses": {"200": {"description": "Role updated", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "400": {"description": "Malformed name or an unknown permission"}, "404": {"description": "Unknown role, a built-in role, or a role belonging to another tenant"}}
},
"delete": {
"tags": ["Role Management"],
"summary": "Delete one of the tenant's own roles",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"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"],
"summary": "Role management screen",
"security": [{"session": []}],
"responses": {"200": {"description": "HTML page"}}
}
},
"/api/v1/users/{id}/approve": {
"post": {
"tags": ["User Management"],
"summary": "Approve a waiting-room user into a tenant",
"description": "Requires a system administrator session.",
"description": "Requires a system administrator session. Unlike the tenant-scoped endpoints this is not limited by the caller's own role, because it grants a first membership across tenants.",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "User approved"}, "404": {"description": "User or permission not found"}}

81
web/profile.html Normal file
View file

@ -0,0 +1,81 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<header>
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav>
<a href="/">Console</a>
<a href="/docs">API docs</a>
<a href="/roles">Roles</a>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
<div data-account-menu></div>
</nav>
</header>
<main>
<section class="hero profile-hero">
<div>
<p class="eyebrow">YOUR ACCOUNT</p>
<h1>Profile<span>.</span></h1>
<p class="lede">Your name, picture, and the tenant roles you hold. Applications such as go-tell read the picture link and your role through the login endpoints.</p>
</div>
</section>
<div id="notice" class="notice" hidden></div>
<section class="profile-grid">
<article class="card profile-card">
<h2>Profile picture</h2>
<div class="avatar-preview">
<img id="avatar-image" alt="" width="112" height="112" hidden>
<span id="avatar-fallback" class="avatar-initials">–</span>
</div>
<form id="avatar-form">
<label>Choose a picture <small>(JPEG, PNG, or GIF, up to 2 MiB)</small><input type="file" id="avatar-file" accept="image/jpeg,image/png,image/gif"></label>
<div class="profile-actions">
<button type="submit" id="avatar-save" disabled>Upload picture</button>
<button type="button" class="ghost" id="avatar-remove" hidden>Remove picture</button>
</div>
</form>
<label class="muted">Picture link <small>(embed it with a plain image tag)</small>
<div class="secret"><code id="avatar-url">No picture uploaded</code><button id="copy-avatar" type="button" hidden>Copy</button></div>
</label>
</article>
<article class="card profile-card">
<h2>Account</h2>
<form id="profile-form">
<label>Display name<input name="display_name" id="display-name" maxlength="200" required></label>
<label>Email<input id="email" readonly></label>
<dl class="facts">
<div><dt>System administrator</dt><dd id="system-admin">—</dd></div>
<div><dt>Member since</dt><dd id="created-at">—</dd></div>
<div><dt>Last login</dt><dd id="last-login">—</dd></div>
<div><dt>Name managed by</dt><dd id="name-source">—</dd></div>
</dl>
<button type="submit">Save profile</button>
</form>
</article>
<article class="card profile-card profile-wide">
<h2>Tenant roles</h2>
<div class="table-wrap"><table><thead><tr><th>Tenant</th><th>Slug</th><th>Role</th><th>Capabilities</th></tr></thead><tbody id="membership-list"></tbody></table></div>
</article>
</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>

155
web/profile.js Normal file
View file

@ -0,0 +1,155 @@
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
const date = value => value ? new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(value)) : 'Never';
const notice = (message, error = false) => {
const el = $('#notice');
el.textContent = message;
el.hidden = false;
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
clearTimeout(notice.timer);
notice.timer = setTimeout(() => el.hidden = true, 5000)
};
const avatar = {dataURL: '', fileName: '', pending: false};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin', ...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})}
});
if (response.status === 401) {
location.href = '/login';
throw new Error('Session expired')
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function load() {
render(await api('/api/v1/profile'))
}
function render(profile) {
$('#display-name').value = profile.display_name;
$('#email').value = profile.email;
$('#system-admin').textContent = profile.is_system_administrator ? 'Yes' : 'No';
$('#created-at').textContent = date(profile.created_at);
$('#last-login').textContent = date(profile.last_login_at);
$('#name-source').textContent = profile.name_customized ? 'You' : 'Your identity provider';
renderAvatar(profile);
$('#membership-list').innerHTML = profile.memberships.map(item => `<tr>
<td>${escapeHTML(item.tenant_name)}</td>
<td><code>${escapeHTML(item.tenant_slug)}</code></td>
<td><span class="badge">${escapeHTML(item.role)}</span></td>
<td class="capabilities">${item.permissions.map(permission => `<span class="chip">${escapeHTML(permission)}</span>`).join('') || '<span class="muted">None</span>'}</td>
</tr>`).join('') || '<tr><td colspan="4">No tenant role assigned yet</td></tr>'
}
function renderAvatar(profile) {
const image = $('#avatar-image'), fallback = $('#avatar-fallback'), link = $('#avatar-url');
const source = avatar.pending ? avatar.dataURL : profile.avatar_url;
$('#avatar-remove').hidden = !profile.avatar_url;
$('#copy-avatar').hidden = !profile.avatar_url;
if (source) {
image.src = source;
image.hidden = false;
fallback.hidden = true
} else {
image.hidden = true;
image.removeAttribute('src');
fallback.hidden = false;
fallback.textContent = initials(profile.display_name || profile.email)
}
link.textContent = profile.avatar_url || (avatar.pending ? 'Saved on upload' : 'No picture uploaded')
}
function initials(name) {
return String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?'
}
$('#profile-form').addEventListener('submit', async event => {
event.preventDefault();
try {
render(await api('/api/v1/profile', {
method: 'PUT',
body: JSON.stringify({display_name: $('#display-name').value})
}));
notice('Profile saved')
} catch (error) {
notice(error.message, true)
}
});
$('#avatar-file').addEventListener('change', async event => {
const file = event.target.files?.[0];
avatar.pending = false;
if (!file) {
$('#avatar-save').disabled = true;
return
}
if (file.size > 2 * 1024 * 1024) {
notice('The picture must be 2 MiB or smaller', true);
event.target.value = '';
$('#avatar-save').disabled = true;
return
}
avatar.dataURL = await readAsDataURL(file);
avatar.fileName = file.name;
avatar.pending = true;
$('#avatar-save').disabled = false;
renderAvatar({avatar_url: '', display_name: $('#display-name').value, email: $('#email').value})
});
$('#avatar-form').addEventListener('submit', async event => {
event.preventDefault();
if (!avatar.pending) return;
try {
const profile = await api('/api/v1/profile', {
method: 'PUT', body: JSON.stringify({avatar: avatar.dataURL})
});
avatar.pending = false;
$('#avatar-file').value = '';
$('#avatar-save').disabled = true;
render(profile);
notice('Profile picture updated')
} catch (error) {
notice(error.message, true)
}
});
$('#avatar-remove').addEventListener('click', async () => {
if (!confirm('Remove your profile picture?')) return;
try {
const profile = await api('/api/v1/profile', {method: 'PUT', body: JSON.stringify({remove_avatar: true})});
avatar.pending = false;
$('#avatar-file').value = '';
$('#avatar-save').disabled = true;
render(profile);
notice('Profile picture removed')
} catch (error) {
notice(error.message, true)
}
});
$('#copy-avatar').addEventListener('click', () => navigator.clipboard.writeText($('#avatar-url').textContent).then(() => notice('Picture link copied')));
function readAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('could not read the selected file'));
reader.readAsDataURL(file)
})
}
// 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));

112
web/roles.html Normal file
View file

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<header>
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav>
<a href="/">Console</a>
<a href="/docs">API docs</a>
<label class="compact">Tenant<select id="tenant-context"></select></label>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<bananas-appearance-toggle id="mode"></bananas-appearance-toggle>
<div data-account-menu></div>
</nav>
</header>
<main>
<section class="hero">
<div>
<p class="eyebrow">ROLE MANAGEMENT</p>
<h1>Who may do<br><span>what, per tenant.</span></h1>
<p class="lede">A role is a named bundle of permissions. Built-in roles are shared by every tenant and cannot be edited; your own tenant can define as many more as it needs.</p>
</div>
<div class="status-card">
<div class="pulse"></div>
<div><strong id="role-summary">Roles</strong><span id="role-tenant">—</span></div>
<code id="role-count">—</code>
</div>
</section>
<div id="notice" class="notice" hidden></div>
<section class="stats" id="stats">
<article><span>Roles</span><strong id="stat-roles">—</strong></article>
<article><span>Built-in</span><strong id="stat-system">—</strong></article>
<article><span>Custom</span><strong id="stat-custom">—</strong></article>
<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>
<button id="new-role">New role</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Role</th><th>Slug</th><th>Permissions</th><th>Members</th><th>Assignable to you</th><th></th></tr></thead>
<tbody id="role-list"></tbody>
</table>
</div>
<p class="muted">You can only create and assign roles that grant no more than you already hold, so managing members can never be used to gain authority.</p>
</section>
<section class="panel active" id="permission-reference">
<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>
<dialog id="role-dialog">
<form id="role-form">
<div class="dialog-head"><h3 id="role-dialog-title">New role</h3><button type="button" class="close">×</button></div>
<input type="hidden" name="role_id">
<label id="role-slug-label">Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="release-manager"></label>
<label>Name<input name="name" required maxlength="100" placeholder="Release manager"></label>
<label>Description <small>(optional)</small><textarea name="description" maxlength="500" placeholder="Prepares releases."></textarea></label>
<fieldset><legend>Permissions</legend><div id="role-permissions" class="check-list"></div></fieldset>
<p class="muted" id="role-hint"></p>
<button type="submit">Save role</button>
</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>

226
web/roles.js Normal file
View file

@ -0,0 +1,226 @@
const state = {tenants: [], roles: [], permissions: [], selectedTenant: ''};
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
const notice = (message, error = false) => {
const el = $('#notice');
el.textContent = message;
el.hidden = false;
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
clearTimeout(notice.timer);
notice.timer = setTimeout(() => el.hidden = true, 5000)
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin', ...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})}
});
if (response.status === 401) {
location.href = '/login';
throw new Error('Session expired')
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
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;
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
// 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()
}
async function loadRoles() {
if (!state.selectedTenant) {
state.roles = [];
return
}
try {
state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles
} catch (error) {
state.roles = [];
notice('You do not have permission to read the roles of this tenant.', true)
}
}
function selectedTenant() {
return state.tenants.find(tenant => tenant.id === state.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();
const custom = state.roles.filter(item => !item.system);
$('#tenant-context').innerHTML = state.tenants.map(item =>
`<option value="${escapeHTML(item.id)}" ${item.id === state.selectedTenant ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
$('#role-tenant').textContent = tenant ? tenant.name : 'No tenant';
$('#role-count').textContent = String(state.roles.length);
$('#role-summary').textContent = canManageRoles() ? 'Roles · editable' : 'Roles · read only';
$('#stat-roles').textContent = state.roles.length;
$('#stat-system').textContent = state.roles.length - custom.length;
$('#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>
<td><code>${escapeHTML(item.slug)}</code></td>
<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><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><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.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('')
}
function openDialog(item) {
const form = $('#role-form');
form.reset();
form.role_id.value = item?.id || '';
form.slug.value = item?.slug || '';
form.name.value = item?.name || '';
form.description.value = item?.description || '';
// The slug identifies a role for the lifetime of the role, so it is fixed
// once the role exists.
$('#role-slug-label').hidden = Boolean(item);
form.slug.required = !item;
$('#role-dialog-title').textContent = item ? `Edit ${item.name}` : 'New role';
$('#role-hint').textContent = item
? 'Saving replaces the permissions of every member holding this role.'
: 'The slug cannot be changed later.';
permissionCheckboxes(item?.permissions);
$('#role-dialog').showModal()
}
$('#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 => {
event.preventDefault();
const form = event.target;
const permissions = [...form.querySelectorAll('input[name=permissions]:checked')].map(input => input.value);
const id = form.role_id.value;
const body = id
? {name: form.name.value, description: form.description.value, permissions}
: {tenant_id: state.selectedTenant, slug: form.slug.value, name: form.name.value, description: form.description.value, permissions};
try {
await api(id ? `/api/v1/roles/${id}` : '/api/v1/roles', {
method: id ? 'PUT' : 'POST',
body: JSON.stringify(body)
});
form.closest('dialog').close();
notice(id ? 'Role updated' : 'Role created');
await loadRoles();
render()
} catch (error) {
notice(error.message, true)
}
});
document.addEventListener('click', async event => {
const edit = event.target.dataset.edit;
if (edit) {
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);
if (!confirm(`Delete the role "${item.name}"?`)) return;
try {
await api(`/api/v1/roles/${remove}`, {method: 'DELETE'});
notice('Role deleted');
await loadRoles();
render()
} catch (error) {
notice(error.message, true)
}
}
});
// 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();
render()
});
load().catch(error => notice(error.message, true));

View file

@ -1,18 +1,56 @@
:root{--bg:#16161e;--surface:#1f2335;--surface2:#24283b;--text:#c0caf5;--muted:#7aa2f7;--accent:#7dcfff;--accent2:#bb9af7;--good:#9ece6a;--danger:#f7768e;--border:#3b4261;--shadow:rgba(0,0,0,.35)}
[data-theme=catppuccin]{--bg:#1e1e2e;--surface:#181825;--surface2:#313244;--text:#cdd6f4;--muted:#89b4fa;--accent:#94e2d5;--accent2:#cba6f7;--good:#a6e3a1;--danger:#f38ba8;--border:#45475a}
[data-theme=tomorrow]{--bg:#1d1f21;--surface:#282a2e;--surface2:#373b41;--text:#c5c8c6;--muted:#81a2be;--accent:#8abeb7;--accent2:#b294bb;--good:#b5bd68;--danger:#cc6666;--border:#4d5057}
[data-theme=kanagawa]{--bg:#1f1f28;--surface:#2a2a37;--surface2:#363646;--text:#dcd7ba;--muted:#7e9cd8;--accent:#7fb4ca;--accent2:#957fb8;--good:#98bb6c;--danger:#e46876;--border:#54546d}
[data-mode=light][data-theme=tokyo]{--bg:#e6e7ed;--surface:#fff;--surface2:#dfe2ed;--text:#343b58;--muted:#34548a;--accent:#0f4b6e;--accent2:#5a4a78;--good:#485e30;--danger:#8c4351;--border:#b4b5c3;--shadow:rgba(52,59,88,.15)}
[data-mode=light][data-theme=catppuccin]{--bg:#eff1f5;--surface:#fff;--surface2:#e6e9ef;--text:#4c4f69;--muted:#1e66f5;--accent:#179299;--accent2:#8839ef;--good:#40a02b;--danger:#d20f39;--border:#bcc0cc;--shadow:rgba(76,79,105,.15)}
[data-mode=light][data-theme=tomorrow]{--bg:#f5f5f5;--surface:#fff;--surface2:#e8e8e8;--text:#4d4d4c;--muted:#4271ae;--accent:#3e999f;--accent2:#8959a8;--good:#718c00;--danger:#c82829;--border:#c9c9c9;--shadow:rgba(77,77,76,.15)}
[data-mode=light][data-theme=kanagawa]{--bg:#f2ecde;--surface:#fffaf0;--surface2:#e7dba9;--text:#545464;--muted:#4d699b;--accent:#4d7f8c;--accent2:#624c83;--good:#6f894e;--danger:#c34043;--border:#c8c093;--shadow:rgba(84,84,100,.15)}
:root{--bg:var(--bn-bg,#16161e);--surface:var(--bn-surface,#1f2335);--surface2:var(--bn-surface-raised,#24283b);--text:var(--bn-text,#c0caf5);--muted:var(--bn-muted,#7aa2f7);--accent:var(--bn-accent,#7dcfff);--accent2:var(--bn-secondary,#bb9af7);--good:var(--bn-good,#9ece6a);--danger:var(--bn-danger,#f7768e);--border:var(--bn-border,#3b4261);--shadow:rgba(0,0,0,.35)}
[data-theme=catppuccin]{--bg:var(--bn-bg,#1e1e2e);--surface:var(--bn-surface,#181825);--surface2:var(--bn-surface-raised,#313244);--text:var(--bn-text,#cdd6f4);--muted:var(--bn-muted,#89b4fa);--accent:var(--bn-accent,#94e2d5);--accent2:var(--bn-secondary,#cba6f7);--good:var(--bn-good,#a6e3a1);--danger:var(--bn-danger,#f38ba8);--border:var(--bn-border,#45475a)}
[data-theme=tomorrow]{--bg:var(--bn-bg,#1d1f21);--surface:var(--bn-surface,#282a2e);--surface2:var(--bn-surface-raised,#373b41);--text:var(--bn-text,#c5c8c6);--muted:var(--bn-muted,#81a2be);--accent:var(--bn-accent,#8abeb7);--accent2:var(--bn-secondary,#b294bb);--good:var(--bn-good,#b5bd68);--danger:var(--bn-danger,#cc6666);--border:var(--bn-border,#4d5057)}
[data-theme=kanagawa]{--bg:var(--bn-bg,#1f1f28);--surface:var(--bn-surface,#2a2a37);--surface2:var(--bn-surface-raised,#363646);--text:var(--bn-text,#dcd7ba);--muted:var(--bn-muted,#7e9cd8);--accent:var(--bn-accent,#7fb4ca);--accent2:var(--bn-secondary,#957fb8);--good:var(--bn-good,#98bb6c);--danger:var(--bn-danger,#e46876);--border:var(--bn-border,#54546d)}
[data-mode=light][data-theme=tokyo]{--bg:var(--bn-bg,#e6e7ed);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#dfe2ed);--text:var(--bn-text,#343b58);--muted:var(--bn-muted,#34548a);--accent:var(--bn-accent,#0f4b6e);--accent2:var(--bn-secondary,#5a4a78);--good:var(--bn-good,#485e30);--danger:var(--bn-danger,#8c4351);--border:var(--bn-border,#b4b5c3);--shadow:rgba(52,59,88,.15)}
[data-mode=light][data-theme=catppuccin]{--bg:var(--bn-bg,#eff1f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e6e9ef);--text:var(--bn-text,#4c4f69);--muted:var(--bn-muted,#1e66f5);--accent:var(--bn-accent,#179299);--accent2:var(--bn-secondary,#8839ef);--good:var(--bn-good,#40a02b);--danger:var(--bn-danger,#d20f39);--border:var(--bn-border,#bcc0cc);--shadow:rgba(76,79,105,.15)}
[data-mode=light][data-theme=tomorrow]{--bg:var(--bn-bg,#f5f5f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e8e8e8);--text:var(--bn-text,#4d4d4c);--muted:var(--bn-muted,#4271ae);--accent:var(--bn-accent,#3e999f);--accent2:var(--bn-secondary,#8959a8);--good:var(--bn-good,#718c00);--danger:var(--bn-danger,#c82829);--border:var(--bn-border,#c9c9c9);--shadow:rgba(77,77,76,.15)}
[data-mode=light][data-theme=kanagawa]{--bg:var(--bn-bg,#f2ecde);--surface:var(--bn-surface,#fffaf0);--surface2:var(--bn-surface-raised,#e7dba9);--text:var(--bn-text,#545464);--muted:var(--bn-muted,#4d699b);--accent:var(--bn-accent,#4d7f8c);--accent2:var(--bn-secondary,#624c83);--good:var(--bn-good,#6f894e);--danger:var(--bn-danger,#c34043);--border:var(--bn-border,#c8c093);--shadow:rgba(84,84,100,.15)}
*{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}
.password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}
.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}