diff --git a/.air.toml b/.air.toml
deleted file mode 100644
index a7ef3a2..0000000
--- a/.air.toml
+++ /dev/null
@@ -1,32 +0,0 @@
-# Air watches this tree and rebuilds into ./dist on every change.
-# Used by `make compose-dev-up` (docker-compose.dev.yaml + Dockerfile.dev).
-# Docs: https://github.com/air-verse/air/blob/master/air_example.toml
-
-root = "/src"
-tmp_dir = "/tmp/air"
-log_file = "/tmp/air/build-errors.log"
-delay = 300
-stop_on_error = true
-stop_on_root_dir = true
-send_interrupt = true
-kill_delay = "2s"
-log_level = "info"
-
-[build]
- # Output lands in ./dist on the host through the bind mount.
- bin = "dist/go-loose"
- cmd = "go build -trimpath -o ./dist/go-loose ./cmd/go-loose"
- args_bin = []
- # web/ is embedded with go:embed, so asset edits need a rebuild too.
- include_ext = ["go", "html", "js", "css", "sql", "json", "yaml", "yml", "toml"]
- include_dir = ["cmd", "internal", "client", "web"]
- # data/db is the PostgreSQL volume and would otherwise trigger rebuilds.
- exclude_dir = [".git", "dist", "data", "docs", "node_modules", "tmp"]
- exclude_regex = ["_test\\.go$"]
- exclude_unchanged = true
- follow_symlink = true
-
-# Bind mounts do not deliver inotify events on Docker Desktop, so poll there.
-[misc]
- poll = false
- poll_interval = 300
diff --git a/.dockerignore b/.dockerignore
index a453382..93e14b7 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,7 +3,4 @@
bin
coverage.out
google_secrets.json
-data
-_data-old-bind-mount
-dist
-tmp
+
diff --git a/.gitignore b/.gitignore
index 5a960dc..cf3bd3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,3 @@ bin/
coverage.out
google_secrets.json
-data/
-_data-old-bind-mount/
-dist/
diff --git a/Dockerfile.dev b/Dockerfile.dev
deleted file mode 100644
index 169b9b5..0000000
--- a/Dockerfile.dev
+++ /dev/null
@@ -1,21 +0,0 @@
-ARG GO_VERSION=1.27.1
-ARG AIR_VERSION=v1.67.4
-
-FROM golang:${GO_VERSION}-alpine
-
-ARG AIR_VERSION
-
-RUN apk add --no-cache ca-certificates git \
- && go install github.com/air-verse/air@${AIR_VERSION}
-
-ENV AIR_VERSION=${AIR_VERSION}
-ENV CGO_ENABLED=0
-ENV GOCACHE=/tmp/go-build
-ENV GOMODCACHE=/go/pkg/mod
-ENV GOFLAGS=-buildvcs=false
-
-WORKDIR /src
-
-EXPOSE 8080
-
-CMD ["air", "-c", ".air.toml"]
diff --git a/Makefile b/Makefile
index 9e70892..1407b5e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,6 @@
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
@@ -10,87 +9,28 @@ GO_LOOSE_DEV_USER ?= admin@local.test
GO_LOOSE_BOOTSTRAP_TENANT ?= nmbs
GO_LOOSE_BOOTSTRAP_APP ?= guess
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true
-GO_LOOSE_DEV_PORT ?= 8080
export
-.PHONY: build test integration-db integration-test behaviour-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet
+.PHONY: build test run compose-up compose-down 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_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
+ go test -race ./...
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:
- GO_LOOSE_BASE_URL= docker compose up --build
+ 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:
- # 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)
+ gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
vet:
go vet ./...
diff --git a/README.md b/README.md
index 56591c4..57d8aea 100644
--- a/README.md
+++ b/README.md
@@ -60,15 +60,10 @@ 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
@@ -82,37 +77,3 @@ 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.
-
diff --git a/client/webauth.go b/client/webauth.go
index 36cbde5..0a9c011 100644
--- a/client/webauth.go
+++ b/client/webauth.go
@@ -41,28 +41,10 @@ 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{}
diff --git a/deploy/helm/go-loose/Chart.yaml b/deploy/helm/go-loose/Chart.yaml
deleted file mode 100644
index 3f6e183..0000000
--- a/deploy/helm/go-loose/Chart.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-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
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/NOTES.txt b/deploy/helm/go-loose/templates/NOTES.txt
deleted file mode 100644
index bc94cbb..0000000
--- a/deploy/helm/go-loose/templates/NOTES.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-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.
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/_helpers.tpl b/deploy/helm/go-loose/templates/_helpers.tpl
deleted file mode 100644
index 3f5e6b7..0000000
--- a/deploy/helm/go-loose/templates/_helpers.tpl
+++ /dev/null
@@ -1,60 +0,0 @@
-{{/*
-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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/deployment.yaml b/deploy/helm/go-loose/templates/deployment.yaml
deleted file mode 100644
index cf51f60..0000000
--- a/deploy/helm/go-loose/templates/deployment.yaml
+++ /dev/null
@@ -1,68 +0,0 @@
-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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/hpa.yaml b/deploy/helm/go-loose/templates/hpa.yaml
deleted file mode 100644
index ab86f6f..0000000
--- a/deploy/helm/go-loose/templates/hpa.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-{{- 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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/ingress.yaml b/deploy/helm/go-loose/templates/ingress.yaml
deleted file mode 100644
index 95e1b28..0000000
--- a/deploy/helm/go-loose/templates/ingress.yaml
+++ /dev/null
@@ -1,36 +0,0 @@
-{{- 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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/secret.yaml b/deploy/helm/go-loose/templates/secret.yaml
deleted file mode 100644
index 5c0fab9..0000000
--- a/deploy/helm/go-loose/templates/secret.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/service.yaml b/deploy/helm/go-loose/templates/service.yaml
deleted file mode 100644
index eee9358..0000000
--- a/deploy/helm/go-loose/templates/service.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/templates/serviceaccount.yaml b/deploy/helm/go-loose/templates/serviceaccount.yaml
deleted file mode 100644
index 6848881..0000000
--- a/deploy/helm/go-loose/templates/serviceaccount.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-{{- 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 }}
\ No newline at end of file
diff --git a/deploy/helm/go-loose/values-prd.yaml b/deploy/helm/go-loose/values-prd.yaml
deleted file mode 100644
index 4ed5fda..0000000
--- a/deploy/helm/go-loose/values-prd.yaml
+++ /dev/null
@@ -1,95 +0,0 @@
-# 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
\ No newline at end of file
diff --git a/deploy/helm/go-loose/values.yaml b/deploy/helm/go-loose/values.yaml
deleted file mode 100644
index 5e49e67..0000000
--- a/deploy/helm/go-loose/values.yaml
+++ /dev/null
@@ -1,128 +0,0 @@
-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: {}
\ No newline at end of file
diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml
deleted file mode 100644
index 3d6f157..0000000
--- a/docker-compose.dev.yaml
+++ /dev/null
@@ -1,87 +0,0 @@
-# Developer stack: docker compose -f docker-compose.dev.yaml up --build
-#
-# Runs Go Loose with Air for hot reload. Air rebuilds on every source change and
-# writes the binary to ./dist, which is bind mounted so you can inspect or run it
-# from the host. Only postgres is published; Go Loose is reached through the
-# proxy or with `docker compose ... exec`.
-#
-# The production-shaped compose file stays at docker-compose.yaml.
-services:
- postgres:
- image: postgres:17-alpine
- ports:
- - "${GO_LOOSE_POSTGRES_PORT:-5433}:5432"
- environment:
- POSTGRES_DB: goloose
- POSTGRES_USER: goloose
- POSTGRES_PASSWORD: goloose
- volumes:
- # Deliberately not the volume docker-compose.yaml uses: `down -v` on this
- # file would otherwise delete the real database. The dev database is
- # expendable and starts empty.
- - db-data-dev:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
- interval: 5s
- timeout: 3s
- retries: 10
- networks:
- - go_loose_dev_network
-
- go-loose:
- build:
- context: .
- dockerfile: Dockerfile.dev
- depends_on:
- postgres:
- condition: service_healthy
- environment:
- GO_LOOSE_ADDR: ":8080"
- GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-http://localhost:8080}"
- GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-localhost}"
- GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable"
- GO_LOOSE_SESSION_SECRET: "${GO_LOOSE_SESSION_SECRET:-local-development-secret-change-me-now}"
- # Dev login skips OIDC so you can work without Google credentials.
- GO_LOOSE_DEV_LOGIN: "${GO_LOOSE_DEV_LOGIN:-true}"
- GO_LOOSE_DEV_USER: "${GO_LOOSE_DEV_USER:-admin@local.test}"
- GO_LOOSE_LOCAL_LOGIN: "${GO_LOOSE_LOCAL_LOGIN:-true}"
- GO_LOOSE_LOCAL_ADMIN_EMAIL: "${GO_LOOSE_LOCAL_ADMIN_EMAIL:-admin@goloose.local}"
- GO_LOOSE_LOCAL_ADMIN_PASSWORD: "${GO_LOOSE_LOCAL_ADMIN_PASSWORD:-}"
- GO_LOOSE_ALLOWED_EMAIL_DOMAINS: "${GO_LOOSE_ALLOWED_EMAIL_DOMAINS:-}"
- GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "${GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS:-true}"
- GO_LOOSE_BOOTSTRAP_TENANT: "${GO_LOOSE_BOOTSTRAP_TENANT:-nmbs}"
- GO_LOOSE_BOOTSTRAP_APP: "${GO_LOOSE_BOOTSTRAP_APP:-guess}"
- GO_LOOSE_OIDC_ISSUER: "${GO_LOOSE_OIDC_ISSUER:-}"
- GO_LOOSE_OIDC_CLIENT_ID: "${GO_LOOSE_OIDC_CLIENT_ID:-}"
- GO_LOOSE_OIDC_CLIENT_SECRET: "${GO_LOOSE_OIDC_CLIENT_SECRET:-}"
- GO_LOOSE_OIDC_REDIRECT_URL: "${GO_LOOSE_OIDC_REDIRECT_URL:-}"
- volumes:
- # Source is mounted read-write so Air can write ./dist. Use
- # :ro here if you only want the host copy of dist to be authoritative.
- - ./:/src:cached
- # The build output, per the Air config above.
- - ./dist:/src/dist
- # Keep build artifacts and the module cache out of the image layer.
- - air-tmp:/tmp/air
- - go-build-cache:/tmp/go-build
- - go-mod-cache:/go/pkg/mod
- ports:
- - "${GO_LOOSE_DEV_PORT:-8080}:8080"
- healthcheck:
- test: ["CMD", "/src/dist/go-loose", "-healthcheck"]
- interval: 10s
- timeout: 3s
- retries: 5
- start_period: 20s
- init: true
- networks:
- - go_loose_dev_network
-
-networks:
- go_loose_dev_network:
-
-volumes:
- db-data-dev:
- air-tmp:
- go-build-cache:
- go-mod-cache:
diff --git a/docker-compose.tst.yaml b/docker-compose.tst.yaml
deleted file mode 100644
index c05d46d..0000000
--- a/docker-compose.tst.yaml
+++ /dev/null
@@ -1,65 +0,0 @@
-# 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:
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index eabdc54..9636f50 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -8,9 +8,7 @@ services:
POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose
volumes:
- # 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
+ - postgres-data2:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
interval: 5s
@@ -25,17 +23,14 @@ services:
condition: service_healthy
environment:
GO_LOOSE_ADDR: ":8080"
- 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_BASE_URL: "https://auth.dev"
+ GO_LOOSE_AUTH_DOMAIN: "auth.dev"
+ GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.dev/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:
@@ -51,8 +46,7 @@ services:
- go-loose
volumes:
- db-data:
-
+ postgres-data2:
networks:
go_loose_network:
local_dev_edge:
diff --git a/docs/client-login.md b/docs/client-login.md
index 8c32522..6ba2891 100644
--- a/docs/client-login.md
+++ b/docs/client-login.md
@@ -120,45 +120,6 @@ 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.
@@ -166,74 +127,7 @@ A user with no membership in the application's tenant still authenticates, but f
- 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`
diff --git a/docs/coding-standards.md b/docs/coding-standards.md
index b25dc7f..b7f71df 100644
--- a/docs/coding-standards.md
+++ b/docs/coding-standards.md
@@ -8,7 +8,6 @@
- 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 ./...`.
diff --git a/docs/installation.md b/docs/installation.md
index c006ed3..04d557a 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -33,9 +33,7 @@ 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 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.
+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.
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:
@@ -80,14 +78,3 @@ 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.
diff --git a/docs/operations.md b/docs/operations.md
index 4b8192f..9caf281 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -37,8 +37,6 @@ 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
diff --git a/docs/roles.md b/docs/roles.md
deleted file mode 100644
index 7b85820..0000000
--- a/docs/roles.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# 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
-
-```
-
-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`.
\ No newline at end of file
diff --git a/internal/avatar/avatar.go b/internal/avatar/avatar.go
deleted file mode 100644
index eeb9cf3..0000000
--- a/internal/avatar/avatar.go
+++ /dev/null
@@ -1,143 +0,0 @@
-// 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 "", ""
-}
diff --git a/internal/avatar/avatar_test.go b/internal/avatar/avatar_test.go
deleted file mode 100644
index 4f57823..0000000
--- a/internal/avatar/avatar_test.go
+++ /dev/null
@@ -1,172 +0,0 @@
-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(``)), 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)
- }
-}
diff --git a/internal/behaviour/application_test.go b/internal/behaviour/application_test.go
deleted file mode 100644
index 4cf1a95..0000000
--- a/internal/behaviour/application_test.go
+++ /dev/null
@@ -1,191 +0,0 @@
-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)
- })
-}
diff --git a/internal/behaviour/clientlogin_test.go b/internal/behaviour/clientlogin_test.go
deleted file mode 100644
index 96d9d04..0000000
--- a/internal/behaviour/clientlogin_test.go
+++ /dev/null
@@ -1,464 +0,0 @@
-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
-}
diff --git a/internal/behaviour/contract_test.go b/internal/behaviour/contract_test.go
deleted file mode 100644
index 06b01e0..0000000
--- a/internal/behaviour/contract_test.go
+++ /dev/null
@@ -1,127 +0,0 @@
-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))
- }
- })
-}
diff --git a/internal/behaviour/harness_test.go b/internal/behaviour/harness_test.go
deleted file mode 100644
index daabd8d..0000000
--- a/internal/behaviour/harness_test.go
+++ /dev/null
@@ -1,460 +0,0 @@
-// 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) }
diff --git a/internal/behaviour/journey_test.go b/internal/behaviour/journey_test.go
deleted file mode 100644
index ab69270..0000000
--- a/internal/behaviour/journey_test.go
+++ /dev/null
@@ -1,466 +0,0 @@
-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, "",
- "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)
- }
- })
-}
diff --git a/internal/behaviour/persistence_test.go b/internal/behaviour/persistence_test.go
deleted file mode 100644
index 7f740c0..0000000
--- a/internal/behaviour/persistence_test.go
+++ /dev/null
@@ -1,248 +0,0 @@
-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)
- })
-}
diff --git a/internal/behaviour/profile_test.go b/internal/behaviour/profile_test.go
deleted file mode 100644
index 0664c92..0000000
--- a/internal/behaviour/profile_test.go
+++ /dev/null
@@ -1,287 +0,0 @@
-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, "= '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")
- }
-}
diff --git a/internal/server/account_test.go b/internal/server/account_test.go
deleted file mode 100644
index 4cd338d..0000000
--- a/internal/server/account_test.go
+++ /dev/null
@@ -1,366 +0,0 @@
-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, `