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 -Ada Lovelace -``` - -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, `
maxPermissionLabel { - writeError(w, http.StatusBadRequest, "label must be between 1 and 100 characters") - return - } - description := strings.TrimSpace(request.Description) - if len(description) > 500 { - writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer") - return - } - created, err := s.store.CreatePermission( - r.Context(), claimsFrom(r).UserID, name, label, description) - if err != nil { - switch { - case errors.Is(err, store.ErrPermissionTaken): - writeError(w, http.StatusConflict, "this permission already exists") - case errors.Is(err, store.ErrUnknownPermission): - writeError(w, http.StatusBadRequest, err.Error()) - default: - s.handleStoreError(w, "create permission", err) - } - return - } - writeJSON(w, http.StatusCreated, created) -} - -// deletePermission removes a catalog entry that no role grants. -func (s *Server) deletePermission(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if !store.ValidPermissionName(name) { - writeError(w, http.StatusBadRequest, "unknown permission name") - return - } - if err := s.store.DeletePermission(r.Context(), claimsFrom(r).UserID, name); err != nil { - switch { - case errors.Is(err, store.ErrPermissionInUse): - writeError(w, http.StatusConflict, "remove this permission from every role before deleting it") - case errors.Is(err, store.ErrPermissionSystem): - writeError(w, http.StatusConflict, "built-in permissions cannot be deleted") - default: - s.handleStoreError(w, "delete permission", err) - } - return - } - w.WriteHeader(http.StatusNoContent) -} diff --git a/internal/server/profile.go b/internal/server/profile.go deleted file mode 100644 index 9fb19a6..0000000 --- a/internal/server/profile.go +++ /dev/null @@ -1,163 +0,0 @@ -package server - -import ( - "bytes" - "errors" - "net/http" - "strings" - "time" - - "github.com/danyel/go-loose/internal/avatar" - "github.com/danyel/go-loose/internal/store" -) - -// maxProfileBody bounds a profile update. It allows an avatar at its 2 MiB -// limit after base64 inflation plus the JSON envelope. -const maxProfileBody = 4 << 20 - -// maxDisplayName bounds a self-selected display name. -const maxDisplayName = 200 - -type profileResponse struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - AvatarURL string `json:"avatar_url"` - Memberships []store.Membership `json:"memberships"` - IsSystemAdministrator bool `json:"is_system_administrator"` - NameCustomized bool `json:"name_customized"` - CreatedAt time.Time `json:"created_at"` - LastLoginAt time.Time `json:"last_login_at"` - ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"` - AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"` -} - -func (s *Server) profilePage(w http.ResponseWriter, _ *http.Request) { - s.serveEmbedded(w, "profile.html") -} - -func (s *Server) profile(w http.ResponseWriter, r *http.Request) { - profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID) - if err != nil { - s.handleStoreError(w, "load profile", err) - return - } - writeJSON(w, http.StatusOK, s.profileResponse(profile)) -} - -func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) { - var request struct { - DisplayName *string `json:"display_name"` - Avatar string `json:"avatar"` - RemoveAvatar bool `json:"remove_avatar"` - } - if !decodeJSONLimit(w, r, maxProfileBody, &request) { - return - } - update := store.ProfileUpdate{} - if request.DisplayName != nil { - name := strings.TrimSpace(*request.DisplayName) - if name == "" || len(name) > maxDisplayName { - writeError(w, http.StatusBadRequest, "display name must be between 1 and 200 characters") - return - } - update.DisplayName = &name - } - switch { - case request.RemoveAvatar && request.Avatar != "": - writeError(w, http.StatusBadRequest, "send either a new picture or remove_avatar, not both") - return - case request.RemoveAvatar: - update.RemoveAvatar = true - case strings.TrimSpace(request.Avatar) != "": - data, contentType, err := avatar.Parse(request.Avatar) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - key, err := avatar.NewKey() - if err != nil { - s.internalError(w, "generate picture key", err) - return - } - update.AvatarKey, update.AvatarData, update.ContentType = key, data, contentType - case update.DisplayName == nil: - writeError(w, http.StatusBadRequest, "a display name or a picture is required") - return - } - if err := s.store.UpdateProfile(r.Context(), claimsFrom(r).UserID, update); err != nil { - s.handleStoreError(w, "update profile", err) - return - } - profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID) - if err != nil { - s.handleStoreError(w, "reload profile", err) - return - } - writeJSON(w, http.StatusOK, s.profileResponse(profile)) -} - -// avatar serves a stored profile picture by its unguessable key. -// -// The route is deliberately public. A profile picture is rendered by other -// applications such as go-tell through a plain image tag, which cannot carry -// the authentication cookie, and the key is 256 bits of randomness rather than -// a user identifier. Nothing else about the user is exposed here. -func (s *Server) avatar(w http.ResponseWriter, r *http.Request) { - key := r.PathValue("key") - if !avatar.ValidKey(key) { - writeError(w, http.StatusNotFound, "picture not found") - return - } - picture, err := s.store.Picture(r.Context(), key) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - writeError(w, http.StatusNotFound, "picture not found") - return - } - s.internalError(w, "load profile picture", err) - return - } - w.Header().Set("Content-Type", picture.ContentType) - w.Header().Set("Cache-Control", "public, max-age=300") - w.Header().Set("ETag", avatar.ETag(picture.Data)) - w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") - http.ServeContent(w, r, "", picture.UpdatedAt, bytes.NewReader(picture.Data)) -} - -func (s *Server) profileResponse(profile store.Profile) profileResponse { - return profileResponse{ - ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName, - AvatarURL: s.avatarPath(profile.AvatarKey), Memberships: profile.Memberships, - IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized, - CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt, - ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt, - } -} - -// avatarPath returns the same-origin path of a stored profile picture. -// -// The picture is served by this application, so a page of this application must -// reference it by a root-relative path. An absolute URL has to be built from -// cfg.BaseURL, and that value is routinely absent, stale, or aimed at a canonical -// address the browser is not using: it defaults to http://localhost:8080, which a -// browser asked to load it from any other origin refuses, and this application's -// own Content-Security-Policy allows images from 'self' only. A relative path is -// correct by construction in every deployment. -func (s *Server) avatarPath(key *string) string { - if key == nil || *key == "" { - return "" - } - return "/api/v1/avatars/" + *key -} - -// avatarURL is the absolute form, for the clients that read a picture from another -// origin: /connect/userinfo is consumed by applications such as go-tell, which -// cannot resolve a relative path against their own host. -func (s *Server) avatarURL(r *http.Request, key *string) string { - path := s.avatarPath(key) - if path == "" { - return "" - } - return s.absoluteURL(r, path) -} diff --git a/internal/server/profile_test.go b/internal/server/profile_test.go deleted file mode 100644 index 5a5f820..0000000 --- a/internal/server/profile_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/base64" - "image" - "image/color" - "image/png" - "net/http" - "net/http/httptest" - "slices" - "strings" - "testing" - - "github.com/danyel/go-loose/internal/config" - "github.com/danyel/go-loose/internal/role" - "github.com/danyel/go-loose/internal/session" - "github.com/danyel/go-loose/internal/store" - "github.com/danyel/go-loose/web" -) - -func profileTestServer() *Server { - return &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}} -} - -func profileRequest(t *testing.T, body string) *http.Request { - t.Helper() - request := httptest.NewRequest(http.MethodPut, "https://auth.example.test/api/v1/profile", strings.NewReader(body)) - claims := session.Claims{UserID: "user-1", Management: true} - return request.WithContext(context.WithValue(request.Context(), claimsKey, claims)) -} - -// The console reads its own picture through avatarPath, so it must be a relative -// path: the configured base URL is absent or stale often enough that building an -// absolute link from it yields an address the browser refuses to load. -func TestAvatarPathIsRelativeAndOmittedWithoutKey(t *testing.T) { - server := profileTestServer() - if got := server.avatarPath(nil); got != "" { - t.Fatalf("avatarPath(nil) = %q, want empty", got) - } - empty := "" - if got := server.avatarPath(&empty); got != "" { - t.Fatalf("avatarPath(empty) = %q, want empty", got) - } - key := "k" - if got, want := server.avatarPath(&key), "/api/v1/avatars/k"; got != want { - t.Fatalf("avatarPath() = %q, want %q", got, want) - } - // A base URL left at its default must not leak into a page's markup. - server.cfg.BaseURL = "http://localhost:8080" - if got := server.avatarPath(&key); got != "/api/v1/avatars/k" { - t.Fatalf("avatarPath() with default base URL = %q", got) - } -} - -// Another origin, such as go-tell reading /connect/userinfo, cannot resolve a -// relative path, so that form follows the request rather than the configuration. -func TestAvatarURLFollowsTheRequestNotTheConfiguredBase(t *testing.T) { - server := profileTestServer() - key := "k" - request := httptest.NewRequest(http.MethodGet, "https://nmbs.auth.example.test/connect/userinfo", nil) - if got, want := server.avatarURL(request, &key), "https://nmbs.auth.example.test/api/v1/avatars/k"; got != want { - t.Fatalf("avatarURL() = %q, want %q", got, want) - } - if got := server.avatarURL(request, nil); got != "" { - t.Fatalf("avatarURL(nil) = %q, want empty", got) - } - // Without a host to follow, the configured base is the only thing left. - if got, want := server.avatarURL(&http.Request{}, &key), "https://auth.example.test/api/v1/avatars/k"; got != want { - t.Fatalf("avatarURL() without a request host = %q, want %q", got, want) - } -} - -// TestClientUserExposesRoleCapabilitiesAndPicture checks that the identity -// handed to a client application carries the capabilities the store resolved for -// the membership's role rather than anything the server infers. -func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) { - server := profileTestServer() - key := "avatar-key" - request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/connect/userinfo", nil) - payload := server.clientUser(request, store.ClientUser{ - ID: "user-1", Email: "operator@example.test", DisplayName: "Operator", - TenantSlug: "nmbs", Application: "guess", Role: "operator", - Permissions: []string{string(role.ManageKeys), string(role.EditProfile)}, - AvatarKey: &key, - }) - if payload.Role != "operator" { - t.Fatalf("role = %q", payload.Role) - } - if !slices.Contains(payload.Permissions, string(role.ManageKeys)) { - t.Fatalf("permissions = %v, want keys.manage", payload.Permissions) - } - if slices.Contains(payload.Permissions, string(role.ManageTenants)) { - t.Fatalf("permissions = %v, must not grant tenants.manage", payload.Permissions) - } - if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") { - t.Fatalf("avatar url = %q", payload.AvatarURL) - } -} - -// TestClientUserWithoutMembershipIsLeastPrivileged documents the pass-through: a -// user with no membership is reported with the permissions of the built-in User -// role, which the store resolves, so an empty capability set reaches clients -// rather than an error. -func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) { - server := profileTestServer() - payload := server.clientUser(nil, store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}}) - if payload.AvatarURL != "" { - t.Fatalf("avatar url = %q, want empty", payload.AvatarURL) - } - if !slices.Equal(payload.Permissions, []string{string(role.EditProfile)}) { - t.Fatalf("permissions = %v, want only profile.edit", payload.Permissions) - } -} - -func TestAvatarRejectsMalformedKeyWithoutQueryingTheDatabase(t *testing.T) { - server := profileTestServer() - for _, key := range []string{"", "short", "../../etc/passwd", strings.Repeat("a", 44)} { - request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/avatars/"+key, nil) - request.SetPathValue("key", key) - response := httptest.NewRecorder() - server.avatar(response, request) - if response.Code != http.StatusNotFound { - t.Fatalf("avatar(%q) status = %d, want %d", key, response.Code, http.StatusNotFound) - } - if body := response.Body.String(); strings.Contains(body, "avatar") { - t.Fatalf("avatar(%q) leaked internals: %s", key, body) - } - } -} - -func TestUpdateProfileRejectsInvalidRequests(t *testing.T) { - server := profileTestServer() - picture := base64.StdEncoding.EncodeToString(tinyPNG(t)) - tests := []struct { - name string - body string - }{ - {name: "unknown field", body: `{"display_name":"Ana","admin":true}`}, - {name: "two JSON objects", body: `{"display_name":"Ana"}{"display_name":"Bo"}`}, - {name: "blank display name", body: `{"display_name":" "}`}, - {name: "oversized display name", body: `{"display_name":"` + strings.Repeat("a", maxDisplayName+1) + `"}`}, - {name: "picture and removal", body: `{"avatar":"` + picture + `","remove_avatar":true}`}, - {name: "picture is not base64", body: `{"avatar":"!!!not base64!!!"}`}, - {name: "picture is not an image", body: `{"avatar":"` + base64.StdEncoding.EncodeToString([]byte("")) + `"}`}, - {name: "picture media type mismatch", body: `{"avatar":"data:image/gif;base64,` + picture + `"}`}, - {name: "nothing to update", body: `{}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - response := httptest.NewRecorder() - server.updateProfile(response, profileRequest(t, test.body)) - if response.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body) - } - }) - } -} - -func TestUpdateProfileRejectsOversizedBody(t *testing.T) { - server := profileTestServer() - body := `{"avatar":"` + strings.Repeat("A", maxProfileBody) + `"}` - response := httptest.NewRecorder() - server.updateProfile(response, profileRequest(t, body)) - if response.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest) - } -} - -func TestProfilePageIsEmbedded(t *testing.T) { - content, err := web.Files.ReadFile("profile.html") - if err != nil { - t.Fatalf("profile.html must be embedded: %v", err) - } - for _, marker := range []string{"avatar-file", "profile-form", "/assets/profile.js"} { - if !bytes.Contains(content, []byte(marker)) { - t.Fatalf("profile.html is missing %q", marker) - } - } -} - -func tinyPNG(t *testing.T) []byte { - t.Helper() - canvas := image.NewRGBA(image.Rect(0, 0, 2, 2)) - canvas.Set(0, 0, color.RGBA{R: 255, A: 255}) - buffer := &bytes.Buffer{} - if err := png.Encode(buffer, canvas); err != nil { - t.Fatalf("encode png: %v", err) - } - return buffer.Bytes() -} diff --git a/internal/server/roles.go b/internal/server/roles.go deleted file mode 100644 index 1bf5cbb..0000000 --- a/internal/server/roles.go +++ /dev/null @@ -1,151 +0,0 @@ -package server - -import ( - "errors" - "net/http" - "strings" - - "github.com/danyel/go-loose/internal/store" -) - -const ( - maxRoleName = 100 - maxRoleDescription = 500 -) - -// rolesPage serves the role management screen. -func (s *Server) rolesPage(w http.ResponseWriter, _ *http.Request) { - s.serveEmbedded(w, "roles.html") -} - -// listRoles serves the role management screen's data: every built-in role plus -// the tenant's own, each annotated with whether the caller may assign it. -func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) { - tenantID, ok := s.requestTenant(w, r) - if !ok { - return - } - roles, err := s.store.ListRoles(r.Context(), claimsFrom(r).UserID, tenantID) - if err != nil { - s.handleStoreError(w, "list roles", err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"roles": roles}) -} - -// createRole adds a role to the tenant. The store refuses a role that grants -// more than the caller holds, so the endpoint does not have to restate that rule. -func (s *Server) createRole(w http.ResponseWriter, r *http.Request) { - var request struct { - TenantID string `json:"tenant_id"` - Slug string `json:"slug"` - Name string `json:"name"` - Description string `json:"description"` - Permissions []string `json:"permissions"` - } - if !decodeJSON(w, r, &request) { - return - } - slug := strings.TrimSpace(request.Slug) - name := strings.TrimSpace(request.Name) - description := strings.TrimSpace(request.Description) - if !validSlug(slug) { - writeError(w, http.StatusBadRequest, "slug must be lowercase letters, digits, and dashes") - return - } - if name == "" || len(name) > maxRoleName { - writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters") - return - } - if len(description) > maxRoleDescription { - writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer") - return - } - created, err := s.store.CreateRole( - r.Context(), claimsFrom(r).UserID, request.TenantID, slug, name, description, request.Permissions) - if err != nil { - if errors.Is(err, store.ErrRoleSlugTaken) { - writeError(w, http.StatusConflict, "a role with this slug already exists in this tenant") - return - } - if errors.Is(err, store.ErrUnknownPermission) { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.handleStoreError(w, "create role", err) - return - } - writeJSON(w, http.StatusCreated, created) -} - -// updateRole renames a role and replaces the permissions it grants. -func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) { - var request struct { - Name string `json:"name"` - Description string `json:"description"` - Permissions []string `json:"permissions"` - } - if !decodeJSON(w, r, &request) { - return - } - name := strings.TrimSpace(request.Name) - description := strings.TrimSpace(request.Description) - if name == "" || len(name) > maxRoleName { - writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters") - return - } - if len(description) > maxRoleDescription { - writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer") - return - } - updated, err := s.store.UpdateRole( - r.Context(), claimsFrom(r).UserID, r.PathValue("id"), name, description, request.Permissions) - if err != nil { - if errors.Is(err, store.ErrUnknownPermission) { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.handleStoreError(w, "update role", err) - return - } - writeJSON(w, http.StatusOK, updated) -} - -// deleteRole removes one of the tenant's own roles. -func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) { - if err := s.store.DeleteRole(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil { - if errors.Is(err, store.ErrRoleInUse) { - writeError(w, http.StatusConflict, "reassign the members of this role before deleting it") - return - } - s.handleStoreError(w, "delete role", err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// requestTenant reads the tenant a request targets, preferring an explicit -// tenant_id and falling back to the tenant named by the host so that the role -// screen works on a tenant subdomain. -func (s *Server) requestTenant(w http.ResponseWriter, r *http.Request) (string, bool) { - if tenantID := strings.TrimSpace(r.URL.Query().Get("tenant_id")); tenantID != "" { - return tenantID, true - } - slug := s.requestTenantSlug(r.Host) - if slug == "" { - writeError(w, http.StatusBadRequest, "tenant_id is required") - return "", false - } - tenants, err := s.store.ListTenants(r.Context(), claimsFrom(r).UserID) - if err != nil { - s.handleStoreError(w, "resolve tenant", err) - return "", false - } - for _, tenant := range tenants { - if tenant.Slug == slug { - return tenant.ID, true - } - } - writeError(w, http.StatusNotFound, "resource not found or permission denied") - return "", false -} diff --git a/internal/server/roles_test.go b/internal/server/roles_test.go deleted file mode 100644 index 237773a..0000000 --- a/internal/server/roles_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package server - -import ( - "bytes" - "context" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "path" - "strings" - "testing" - - "github.com/danyel/go-loose/internal/config" - "github.com/danyel/go-loose/internal/session" - "github.com/danyel/go-loose/web" -) - -// withClaims injects a management session the way requireSession would, so a -// handler can be called directly without standing up a session manager. -func withClaims(request *http.Request, claims session.Claims) context.Context { - return context.WithValue(request.Context(), claimsKey, claims) -} - -func TestRolesPageIsEmbedded(t *testing.T) { - content, err := web.Files.ReadFile("roles.html") - if err != nil { - t.Fatalf("roles.html must be embedded: %v", err) - } - for _, marker := range []string{ - "role-list", "role-form", "permission-list", "tenant-context", "/assets/roles.js", - } { - if !bytes.Contains(content, []byte(marker)) { - t.Errorf("roles.html is missing %q", marker) - } - } -} - -// TestRolesScriptIsServed guards the asset allowlist: roles.js is a new file, and -// the handler serves assets by exact name rather than by pattern. -func TestRolesScriptIsServed(t *testing.T) { - server := profileTestServer() - request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/roles.js", nil) - request.SetPathValue("name", "roles.js") - response := httptest.NewRecorder() - - server.asset(response, request) - - if response.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", response.Code, http.StatusOK) - } - if !strings.Contains(response.Body.String(), "/api/v1/roles") { - t.Error("roles.js does not call the role endpoints") - } -} - -func TestAssetAllowlistRejectsUnknownFiles(t *testing.T) { - server := profileTestServer() - for _, name := range []string{"admin.js", "roles.html", "app.js.map", "secret.env"} { - request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil) - request.SetPathValue("name", name) - response := httptest.NewRecorder() - - server.asset(response, request) - - if response.Code != http.StatusNotFound { - t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound) - } - } -} - -// TestAssetTraversalCollapsesToTheAllowlistedName documents that a traversal -// attempt cannot escape the allowlist: path.Base reduces it to the bare file -// name, which is then matched against the list like any other request. -func TestAssetTraversalCollapsesToTheAllowlistedName(t *testing.T) { - server := profileTestServer() - for _, name := range []string{"../roles.js", "../../etc/passwd", "..%2froles.js"} { - request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil) - request.SetPathValue("name", name) - response := httptest.NewRecorder() - - server.asset(response, request) - - served := path.Base(name) - if served == "roles.js" { - if response.Code != http.StatusOK { - t.Errorf("asset(%q) status = %d, want the allowlisted file to serve", name, response.Code) - } - continue - } - if response.Code != http.StatusNotFound { - t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound) - } - } -} - -func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) { - cases := []struct { - name string - body string - }{ - {"missing slug", `{"name":"Role","permissions":[]}`}, - {"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`}, - {"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`}, - {"empty name", `{"slug":"role","name":" ","permissions":[]}`}, - } - // Permission names are not checked here: the catalog is a table, so an unknown - // name can only be caught by the store, which reports it as a bad request. - // See TestARoleCannotGrantAPermissionThatIsNotInTheCatalog. - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - server := profileTestServer() - request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/api/v1/roles", - strings.NewReader(testCase.body)) - request.SetPathValue("id", "") - request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true})) - response := httptest.NewRecorder() - - server.createRole(response, request) - - // The store would need a database, so a 400 proves validation ran first. - if response.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body) - } - }) - } -} - -func TestListRolesRequiresATenant(t *testing.T) { - server := profileTestServer() - request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/roles", nil) - request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true})) - response := httptest.NewRecorder() - - server.listRoles(response, request) - - if response.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest) - } - if !strings.Contains(response.Body.String(), "tenant_id") { - t.Errorf("body = %s, want it to name the missing parameter", response.Body) - } -} - -// TestRoleRoutesAreRegistered documents the two wrappers the role routes rely -// on. The server under test is not installed, so requireSession redirects every -// session-guarded route to the installer; an unregistered route would answer 404 -// instead, which is what makes this a registration check. -func TestRoleRoutesAreRegistered(t *testing.T) { - // Handler() wraps every route in the request logger, so this server needs a - // logger where the profile tests, which call handlers directly, do not. - server := &Server{ - cfg: config.Config{BaseURL: "https://auth.example.test"}, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - } - handler := server.Handler() - cases := []struct { - method, path string - }{ - {http.MethodGet, "/api/v1/roles?tenant_id=t"}, - {http.MethodPost, "/api/v1/roles"}, - {http.MethodPut, "/api/v1/roles/abc"}, - {http.MethodDelete, "/api/v1/roles/abc"}, - {http.MethodGet, "/roles"}, - } - for _, testCase := range cases { - t.Run(testCase.method+" "+testCase.path, func(t *testing.T) { - request := httptest.NewRequest(testCase.method, "https://auth.example.test"+testCase.path, nil) - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want %d from requireSession", response.Code, http.StatusSeeOther) - } - if location := response.Header().Get("Location"); location != "/install" { - t.Fatalf("location = %q, want /install", location) - } - }) - } -} diff --git a/internal/server/server.go b/internal/server/server.go index 88b6c21..8d4c3ee 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,7 +1,6 @@ package server import ( - "bytes" "context" "database/sql" "encoding/json" @@ -31,16 +30,12 @@ import ( ) type Server struct { - cfg config.Config - store *store.Store - db *sql.DB - sessions *session.Manager - oauth *oauth2.Config - verifier *oidc.IDTokenVerifier - // endSession is the identity provider's logout endpoint, empty when the - // provider does not advertise one. It is what lets a sign-out here also end - // the provider session the other applications in the family share. - endSession string + cfg config.Config + store *store.Store + db *sql.DB + sessions *session.Manager + oauth *oauth2.Config + verifier *oidc.IDTokenVerifier authMu sync.RWMutex installed atomic.Bool httpClient *http.Client @@ -74,7 +69,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger if decryptErr != nil { return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr) } - if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret, installation.OIDCRedirectURL); err != nil { + if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret); err != nil { return nil, err } server.installed.Store(true) @@ -84,7 +79,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger } else if !errors.Is(err, store.ErrNotFound) { return nil, fmt.Errorf("load installation: %w", err) } else if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" { - if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURL); err != nil { + if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret); err != nil { return nil, err } server.installed.Store(true) @@ -112,7 +107,6 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /auth/logout", s.authLogout) mux.HandleFunc("POST /auth/password", s.passwordLogin) mux.HandleFunc("GET /assets/{name}", s.asset) - mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar) mux.HandleFunc("POST /api/v1/authorize", s.authorize) mux.HandleFunc("GET /connect/authorize", s.clientAuthorize) mux.HandleFunc("POST /connect/token", s.clientToken) @@ -123,10 +117,6 @@ func (s *Server) Handler() http.Handler { // {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a // browser favicon request would redirect to /install and rotate its CSRF cookie. mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index))) - mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage))) - mux.Handle("GET /roles", s.requireSession(http.HandlerFunc(s.rolesPage))) - mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile))) - mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile)))) mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs))) mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi))) mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard))) @@ -139,13 +129,6 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser)))) mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess)))) mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser)))) - mux.Handle("GET /api/v1/roles", s.requireSession(http.HandlerFunc(s.listRoles))) - mux.Handle("POST /api/v1/roles", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createRole)))) - mux.Handle("PUT /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateRole)))) - mux.Handle("DELETE /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deleteRole)))) - mux.Handle("GET /api/v1/permissions", s.requireSession(http.HandlerFunc(s.listPermissions))) - mux.Handle("POST /api/v1/permissions", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createPermission)))) - mux.Handle("DELETE /api/v1/permissions/{name}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deletePermission)))) return s.recover(s.logRequests(s.securityHeaders(mux))) } @@ -172,26 +155,16 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) { } func (s *Server) index(w http.ResponseWriter, r *http.Request) { - claims := claimsFrom(r) - console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID) + allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID) if err != nil { - s.internalError(w, "check console access", err) + s.internalError(w, "check management access", err) return } - if console { - s.serveEmbedded(w, "index.html") + if !allowed { + s.serveEmbedded(w, "waiting-room.html") return } - member, err := s.store.HasMembership(r.Context(), claims.UserID) - if err != nil { - s.internalError(w, "check membership", err) - return - } - if member { - http.Redirect(w, r, "/profile", http.StatusSeeOther) - return - } - s.serveEmbedded(w, "waiting-room.html") + s.serveEmbedded(w, "index.html") } func (s *Server) docs(w http.ResponseWriter, _ *http.Request) { @@ -204,8 +177,7 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) { func (s *Server) asset(w http.ResponseWriter, r *http.Request) { name := path.Base(r.PathValue("name")) - if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" && - name != "account.js" { + if name != "style.css" && name != "app.js" { http.NotFound(w, r) return } @@ -218,62 +190,10 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, name string) { http.Error(w, "asset unavailable", http.StatusInternalServerError) return } - - body := content - // HTML pages get the theme origin substituted in. It is a placeholder rather than - // a literal because the origin is deployment configuration, not build-time. - // - // With no origin configured the two theme lines are removed outright rather than - // emitted with an empty href. An empty href is a same-origin request to this - // application, which 404s: a broken stylesheet request in the console, and a - // styling bug report that points at the wrong service entirely. - if path.Ext(name) == ".html" { - body = applyThemeOrigin(content, s.cfg.ThemeBaseURL) - } - w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name))) - w.Write(body) + w.Write(content) } -// themeOriginPlaceholder is substituted into every HTML page. It is deliberately not -// valid markup, so an unsubstituted page is obviously wrong rather than subtly so. -const themeOriginPlaceholder = "__GO_LOOSE_THEME_ORIGIN__" - -// applyThemeOrigin substitutes the theme origin into an HTML page, or removes the -// theme lines entirely when none is configured. -func applyThemeOrigin(content []byte, base string) []byte { - // TrimSpace as well as the trailing slash: an environment variable set to spaces - // is a plausible accident, and it would otherwise produce a relative URL that - // resolves against this application and 404s. - origin := strings.TrimRight(strings.TrimSpace(base), "/") - if origin == "" { - return dropThemeLines(content) - } - return bytes.ReplaceAll(content, []byte(themeOriginPlaceholder), []byte(origin)) -} - -// dropThemeLines removes every line carrying a theme marker attribute. Markers are -// used rather than the placeholder itself so that the and the - -` - -func TestThemeOriginIsSubstituted(t *testing.T) { - got := string(applyThemeOrigin([]byte(pageWithThemeLines), "https://themes.example.com")) - - if strings.Contains(got, themeOriginPlaceholder) { - t.Error("the placeholder survived substitution") - } - if !strings.Contains(got, `href="https://themes.example.com/rt/v1/contract.css"`) { - t.Errorf("the contract stylesheet was not rewritten:\n%s", got) - } - if !strings.Contains(got, `src="https://themes.example.com/rt/v1/components.js"`) { - t.Errorf("the component module was not rewritten:\n%s", got) - } - // The application's own stylesheet must be untouched. - if !strings.Contains(got, `href="/assets/style.css"`) { - t.Error("the local stylesheet was rewritten") - } -} - -func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) { - // A trailing slash would produce a double slash, and the service answers 404 for - // "//rt/v1/contract.css" because the route is matched on the clean path. - for _, base := range []string{ - "https://themes.example.com/", - "https://themes.example.com///", - } { - got := string(applyThemeOrigin([]byte(pageWithThemeLines), base)) - if strings.Contains(got, "com//") { - t.Errorf("base %q produced a doubled slash:\n%s", base, got) - } - } -} - -func TestThemeLinesAreDroppedWhenNoOriginIsConfigured(t *testing.T) { - for _, base := range []string{"", " ", "/"} { - got := string(applyThemeOrigin([]byte(pageWithThemeLines), base)) - - if strings.Contains(got, themeOriginPlaceholder) { - t.Errorf("base %q left the placeholder in place:\n%s", base, got) - } - // An empty href would be a same-origin request to this application, which 404s - // and looks like a styling bug in the wrong service. - if strings.Contains(got, `href="/rt/v1/contract.css"`) { - t.Errorf("base %q emitted an empty contract href:\n%s", base, got) - } - if strings.Contains(got, themeMarkerContract) || strings.Contains(got, themeMarkerScript) { - t.Errorf("base %q left a theme line behind:\n%s", base, got) - } - // The rest of the page must survive. - if !strings.Contains(got, `href="/assets/style.css"`) { - t.Errorf("base %q removed the local stylesheet:\n%s", base, got) - } - if !strings.Contains(got, "") || !strings.Contains(got, "") { - t.Errorf("base %q damaged the page structure:\n%s", base, got) - } - } -} - -func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) { - // A page with no theme lines must come back byte-identical, so that a deployment - // without the contract serving its login and install pages is a no-op. - plain := []byte("\n\nno theme here\n\n") - if got := applyThemeOrigin(plain, "https://themes.example.com"); string(got) != string(plain) { - t.Errorf("a page without theme lines was modified:\n%s", got) - } - if got := applyThemeOrigin(plain, ""); string(got) != string(plain) { - t.Errorf("a page without theme lines was modified:\n%s", got) - } -} - -// contractBackedTokens maps this application's own colour token onto the shared -// contract role that carries the same meaning. The right-hand side is a bare role -// name because the variable prefix belongs to the contract. -var contractBackedTokens = map[string]string{ - "--bg": "bg", - "--surface": "surface", - // go-loose's second surface step is the contract's raised surface. - "--surface2": "surface-raised", - "--text": "text", - "--muted": "muted", - "--accent": "accent", - // go-loose's secondary accent is the contract's secondary role: for every - // palette the two agree, tokyo's #bb9af7 and catppuccin's #cba6f7 among them. - "--accent2": "secondary", - "--good": "good", - "--danger": "danger", - "--border": "border", -} - -// TestLocalTokensAdoptTheContractWhereItIsServed covers the difference between a -// theme selector that is present in the page and one that works. -// -// Every palette this application names has to resolve through the contract's -// variables, with this application's own value as the fallback. The fallback is -// what keeps the install and login pages styled as they always were: they never load -// the contract, so the variable is undefined there and the literal applies. Loading -// the contract on a console page therefore adopts the shared palette without -// changing anything for the pages that do not ask for it. -// -// Both directions are asserted. Adopting the role is not enough on its own: a single -// declaration left holding a bare literal overrides the contract for whichever -// palette that block names, and the console then renders half shared and half local. -func TestLocalTokensAdoptTheContractWhereItIsServed(t *testing.T) { - css, err := web.Files.ReadFile("style.css") - if err != nil { - t.Fatalf("style.css must be embedded: %v", err) - } - stylesheet := string(css) - - for token, role := range contractBackedTokens { - if !strings.Contains(stylesheet, fmt.Sprintf("%s:var(--bn-%s,", token, role)) { - t.Errorf("token %s does not adopt --bn-%s, so selecting a palette outside this application's own four changes nothing:\n%s", - token, role, firstLines(css, 8)) - } - // A literal assignment anywhere would win for the palette it sits under, and - // the contract's value for that one palette would be discarded. - if literal := token + ":#"; strings.Contains(stylesheet, literal) { - t.Errorf("token %s is still assigned a bare literal, which overrides the contract for the palette that block names:\n%s", - token, firstLines(css, 8)) - } - } -} - -// TestTheShadowTokenIsNotMapped guards the one token deliberately left alone. -// The contract's shadow family holds complete box-shadow values, and this token -// holds a colour, so wiring them together would substitute a shadow for a colour. -func TestTheShadowTokenIsNotMapped(t *testing.T) { - css, err := web.Files.ReadFile("style.css") - if err != nil { - t.Fatalf("style.css must be embedded: %v", err) - } - if strings.Contains(string(css), "--shadow:var(--bn-") { - t.Error("--shadow is a colour here and the contract's shadow roles are box-shadow values; mapping them would break every elevation") - } -} - -// firstLines trims a stylesheet for an error message, since the token blocks sit at -// the top of the file and the whole of it is noise in a test failure. -func firstLines(css []byte, n int) string { - lines := strings.SplitN(string(css), "\n", n+1) - if len(lines) > n { - lines = lines[:n] - } - return strings.Join(lines, "\n") -} diff --git a/internal/server/useradmin.go b/internal/server/useradmin.go index 94cea56..7a83f3b 100644 --- a/internal/server/useradmin.go +++ b/internal/server/useradmin.go @@ -59,7 +59,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { TenantID string `json:"tenant_id"` Email string `json:"email"` DisplayName string `json:"display_name"` - RoleID string `json:"role_id"` + Role string `json:"role"` Password string `json:"password"` } if !decodeJSON(w, r, &request) { @@ -75,8 +75,8 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { if request.DisplayName == "" { request.DisplayName = request.Email } - if strings.TrimSpace(request.RoleID) == "" { - writeError(w, http.StatusBadRequest, "role_id is required") + if request.Role != "admin" && request.Role != "viewer" { + writeError(w, http.StatusBadRequest, "role must be admin or viewer") return } passwordHash, err := password.Hash(request.Password) @@ -84,29 +84,28 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.RoleID, passwordHash) + user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash) if err != nil { s.handleStoreError(w, "invite user", err) return } - user.AvatarURL = s.avatarPath(user.AvatarKey) writeJSON(w, http.StatusCreated, user) } func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) { var request struct { TenantID string `json:"tenant_id"` - RoleID string `json:"role_id"` + Role string `json:"role"` ApplicationIDs []string `json:"application_ids"` } if !decodeJSON(w, r, &request) { return } - if strings.TrimSpace(request.RoleID) == "" { - writeError(w, http.StatusBadRequest, "role_id is required") + if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" { + writeError(w, http.StatusBadRequest, "invalid role") return } - if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.RoleID, request.ApplicationIDs); err != nil { + if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil { s.handleStoreError(w, "update user access", err) return } @@ -116,19 +115,19 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) { func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) { var request struct { TenantID string `json:"tenant_id"` - RoleID string `json:"role_id"` + Role string `json:"role"` ApplicationIDs []string `json:"application_ids"` } if !decodeJSON(w, r, &request) { return } - if strings.TrimSpace(request.RoleID) == "" { - writeError(w, http.StatusBadRequest, "role_id is required") + if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" { + writeError(w, http.StatusBadRequest, "invalid role") return } if err := s.store.ApprovePendingUser( r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), - request.RoleID, request.ApplicationIDs, + request.Role, request.ApplicationIDs, ); err != nil { s.handleStoreError(w, "approve pending user", err) return diff --git a/internal/store/application_access_integration_test.go b/internal/store/application_access_integration_test.go deleted file mode 100644 index 835dbdd..0000000 --- a/internal/store/application_access_integration_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package store_test - -import ( - "errors" - "testing" - - "github.com/danyel/go-loose/internal/store" -) - -// TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications pins the -// boundary between the management plane and the client plane. -// -// Owning every tenant is what lets the first administrator reach the whole -// installation from the console. It is not permission to sign in to the -// applications running inside those tenants: ClientForAuthorization answers for a -// user_application_access row and nothing else, so a claim that inserted one per -// application would ship every installation with a standing administrator -// account inside every customer application. Access to an application is an -// explicit grant, and this test also pins that such a grant is still possible on -// purpose, because an administrator is allowed to attach themselves. -func TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications(t *testing.T) { - s, _, ctx := integrationStore(t) - - // Two tenants, each with a client configured, so that "every application" is - // not satisfied by a single row. - firstOwner := newUser(t, s, ctx, "first@example.test") - first := newTenant(t, s, ctx, firstOwner, "nmbs") - secondOwner := newUser(t, s, ctx, "second@example.test") - second := newTenant(t, s, ctx, secondOwner, "ypto") - - const redirect = "https://app.example.test/callback" - application := func(owner, tenantID, slug string) store.Application { - t.Helper() - created, err := s.CreateApplication(ctx, owner, store.Application{ - TenantID: tenantID, Slug: slug, Name: slug, AllowedHosts: []string{}, - }) - if err != nil { - t.Fatalf("create application %s: %v", slug, err) - } - if _, err := s.ConfigureClient(ctx, owner, created.ID, []string{redirect}, secretHash(slug)); err != nil { - t.Fatalf("configure client login for %s: %v", slug, err) - } - return created - } - firstApp := application(firstOwner, first.ID, "guess") - secondApp := application(secondOwner, second.ID, "tell") - - admin := newUser(t, s, ctx, "admin@example.test") - claimed, err := s.ClaimFirstSystemAdministrator(ctx, admin) - if err != nil { - t.Fatalf("claim first system administrator: %v", err) - } - if !claimed { - t.Fatal("the first user to sign in must become the system administrator") - } - - // The management plane still reaches everything: the claim owns both tenants, - // which is what the console needs to administer them. - console, err := s.HasConsoleAccess(ctx, admin) - if err != nil { - t.Fatalf("HasConsoleAccess: %v", err) - } - if !console { - t.Error("a system administrator must have console access") - } - tenants, err := s.ListTenants(ctx, admin) - if err != nil { - t.Fatalf("ListTenants: %v", err) - } - if len(tenants) != 2 { - t.Errorf("the administrator owns %d tenants, want 2", len(tenants)) - } - - // The client plane does not. No application was granted, so neither endpoint - // that mints or resolves a hosted-login session will recognize them. - for _, test := range []struct { - name string - application store.Application - }{ - {name: "nmbs", application: firstApp}, - {name: "ypto", application: secondApp}, - } { - t.Run(test.name, func(t *testing.T) { - if _, err := s.ClientForAuthorization(ctx, test.application.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("ClientForAuthorization = %v, want ErrNotFound: owning a tenant must not open its applications", err) - } - if _, err := s.ClientUserByToken(ctx, secretHash("no-session")); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("ClientUserByToken = %v, want ErrNotFound", err) - } - }) - } - - // Attaching the administrator to one application is allowed, and it is the only - // way in. They hold users.manage in every tenant by virtue of owning it, which - // is what makes the deliberate act possible. - ownerRole, err := s.SystemRoleID(ctx, "owner") - if err != nil { - t.Fatalf("look up the owner role: %v", err) - } - if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, []string{firstApp.ID}); err != nil { - t.Fatalf("an administrator must be able to attach themselves to an application: %v", err) - } - if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); err != nil { - t.Fatalf("ClientForAuthorization after an explicit grant = %v, want the application", err) - } - - // Withdrawing it takes effect immediately and leaves the console untouched. - if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, nil); err != nil { - t.Fatalf("withdraw the grant: %v", err) - } - if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("ClientForAuthorization after withdrawal = %v, want ErrNotFound", err) - } - console, err = s.HasConsoleAccess(ctx, admin) - if err != nil { - t.Fatalf("HasConsoleAccess: %v", err) - } - if !console { - t.Error("withdrawing an application grant must not cost the administrator their console") - } -} diff --git a/internal/store/clientauth.go b/internal/store/clientauth.go index cd3e3c8..482c585 100644 --- a/internal/store/clientauth.go +++ b/internal/store/clientauth.go @@ -6,8 +6,6 @@ import ( "encoding/json" "errors" "time" - - "github.com/danyel/go-loose/internal/role" ) type ManagedUser struct { @@ -16,14 +14,8 @@ type ManagedUser struct { Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` - RoleID string `json:"role_id"` - Permissions []string `json:"permissions"` Status string `json:"status"` ApplicationIDs []string `json:"application_ids"` - // AvatarKey is the stored locator. The server turns it into the absolute - // AvatarURL returned to clients. - AvatarKey *string `json:"-"` - AvatarURL string `json:"avatar_url"` } type ClientApplication struct { @@ -44,27 +36,12 @@ type ClientUser struct { TenantSlug string `json:"tenant_slug"` ApplicationID string `json:"application_id"` Application string `json:"application"` - // Role is the membership role in the tenant owning the application. A user - // without a membership is reported as User so clients always see the least - // privileged role. - Role string `json:"role"` - // Permissions is read from the role's own rows. A user without a membership - // gets the permissions of the built-in User role. - Permissions []string `json:"permissions"` - // AvatarKey is the stored locator; the server expands it into AvatarURL. - AvatarKey *string `json:"-"` - AvatarURL string `json:"avatar_url"` } func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT u.id, m.tenant_id, u.email, u.display_name, r.slug, m.role_id, + SELECT u.id, m.tenant_id, u.email, u.display_name, m.role, CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END, - u.avatar_key, - COALESCE(( - SELECT json_agg(rp.permission ORDER BY rp.permission) - FROM role_permissions rp WHERE rp.role_id = m.role_id - ), '[]'::json), COALESCE(( SELECT json_agg(ua.application_id ORDER BY ua.application_id) FROM user_application_access ua @@ -73,9 +50,12 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ ), '[]'::json) FROM users u JOIN memberships m ON m.user_id = u.id - JOIN roles r ON r.id = m.role_id - WHERE `+permissionPredicate("m.tenant_id", "$1", "$2")+` - ORDER BY u.email, m.tenant_id`, administratorID, string(role.ManageUsers)) + WHERE EXISTS ( + SELECT 1 FROM memberships own + WHERE own.tenant_id = m.tenant_id AND own.user_id = $1 + AND own.role IN ('owner', 'admin') + ) + ORDER BY u.email, m.tenant_id`, administratorID) if err != nil { return nil, err } @@ -83,12 +63,8 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ result := make([]ManagedUser, 0) for rows.Next() { var user ManagedUser - var permissions, applicationIDs []byte - if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, - &user.RoleID, &user.Status, &user.AvatarKey, &permissions, &applicationIDs); err != nil { - return nil, err - } - if err := json.Unmarshal(permissions, &user.Permissions); err != nil { + var applicationIDs []byte + if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil { return nil, err } if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil { @@ -99,30 +75,23 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ return result, rows.Err() } -// InviteUser adds a member to a tenant or, when the email already exists, -// re-invites them. The caller must hold users.manage in that tenant and may only -// hand out a role whose permissions they hold themselves. -func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, roleID string, passwordHash string) (ManagedUser, error) { +func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return ManagedUser{}, err } defer tx.Rollback() - permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers) - if err != nil { + var permitted bool + if err := tx.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM memberships + WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin') + )`, tenantID, administratorID).Scan(&permitted); err != nil { return ManagedUser{}, err } if !permitted { return ManagedUser{}, ErrNotFound } - grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID) - if err != nil { - return ManagedUser{}, err - } - resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable) - if err != nil { - return ManagedUser{}, err - } var user ManagedUser err = tx.QueryRowContext(ctx, ` INSERT INTO users(subject, email, display_name, password_hash, password_changed_at) @@ -138,69 +107,36 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email return ManagedUser{}, err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role_id) VALUES ($1, $2, $3) - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`, - tenantID, user.ID, resolved); err != nil { + INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, + tenantID, user.ID, role); err != nil { return ManagedUser{}, err } if err := tx.Commit(); err != nil { return ManagedUser{}, err } - granted, err := roleGrants(ctx, s.db, resolved) - if err != nil { - return ManagedUser{}, err - } - user.TenantID, user.RoleID, user.ApplicationIDs = tenantID, resolved, []string{} - if err := s.db.QueryRowContext(ctx, `SELECT slug FROM roles WHERE id = $1`, resolved).Scan(&user.Role); err != nil { - return ManagedUser{}, err - } - user.Permissions = granted + user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{} return user, nil } -// SetUserAccess changes a member's role and replaces their application grants. -func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error { +func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() - permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers) - if err != nil { - return err - } - if !permitted { - return ErrNotFound - } - grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID) - if err != nil { - return err - } - resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable) - if err != nil { - return err - } - if err := ensureTenantKeepsAnOwner(ctx, tx, tenantID, userID, resolved); err != nil { - return err - } result, err := tx.ExecContext(ctx, ` - UPDATE memberships SET role_id = $3 - WHERE tenant_id = $1 AND user_id = $2 AND role_id IS DISTINCT FROM $3`, - tenantID, userID, resolved) + UPDATE memberships target SET role = $4 + FROM memberships administrator + WHERE target.tenant_id = $1 AND target.user_id = $2 + AND administrator.tenant_id = target.tenant_id + AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`, + tenantID, userID, administratorID, role) if err != nil { return err } if count, _ := result.RowsAffected(); count == 0 { - // Either the membership does not exist or the role is unchanged. - var exists bool - if err := tx.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)`, - tenantID, userID).Scan(&exists); err != nil { - return err - } - if !exists { - return ErrNotFound - } + return ErrNotFound } if _, err := tx.ExecContext(ctx, ` DELETE FROM user_application_access ua @@ -224,49 +160,17 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us return tx.Commit() } -// ErrLastOwner is returned when a change would leave a tenant without an owner. -var ErrLastOwner = errors.New("a tenant must keep at least one owner") - -// ensureTenantKeepsAnOwner blocks demoting or moving away the only membership -// that still holds the owner role. Without this a tenant administrator could -// lock every administrator out of their own tenant with a single edit. -func ensureTenantKeepsAnOwner(ctx context.Context, tx *sql.Tx, tenantID, userID, newRoleID string) error { - var losesOwner bool - if err := tx.QueryRowContext(ctx, ` - SELECT EXISTS( - SELECT 1 FROM memberships m - JOIN roles r ON r.id = m.role_id - WHERE m.tenant_id = $1 AND m.user_id = $2 AND r.slug = 'owner' AND m.role_id <> $3 - )`, tenantID, userID, newRoleID).Scan(&losesOwner); err != nil { - return err - } - if !losesOwner { - return nil - } - var remaining int - if err := tx.QueryRowContext(ctx, ` - SELECT count(*) - FROM memberships m - JOIN roles r ON r.id = m.role_id - WHERE m.tenant_id = $1 AND r.slug = 'owner' AND m.user_id <> $2`, - tenantID, userID).Scan(&remaining); err != nil { - return err - } - if remaining == 0 { - return ErrLastOwner - } - return nil -} - func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) { var app Application var encoded []byte err := s.db.QueryRowContext(ctx, ` UPDATE applications a - SET redirect_uris = $4, client_secret_hash = $5, updated_at = now() - WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$2", "$3")+` + SET redirect_uris = $3, client_secret_hash = $4, updated_at = now() + FROM memberships m + WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2 + AND m.role IN ('owner', 'admin') RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`, - applicationID, administratorID, string(role.ManageApplications), redirectURIs, secretHash). + applicationID, administratorID, redirectURIs, secretHash). Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady) if errors.Is(err, sql.ErrNoRows) { return Application{}, ErrNotFound @@ -340,33 +244,22 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec } defer tx.Rollback() var user ClientUser - var permissions []byte err = tx.QueryRowContext(ctx, ` UPDATE client_authorization_codes c SET used_at = now() - FROM applications a - JOIN tenants t ON t.id = a.tenant_id - JOIN user_application_access ua ON ua.application_id = a.id - JOIN users u ON u.id = ua.user_id - LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id - LEFT JOIN roles r ON r.id = m.role_id + FROM applications a, tenants t, users u WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now() AND c.redirect_uri = $2 AND a.id = c.application_id - AND ua.user_id = c.user_id AND ua.application_id = a.id AND a.client_id = $3 AND a.client_secret_hash = $4 - RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug, - COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`, + AND t.id = a.tenant_id AND u.id = c.user_id + RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`, codeHash, redirectURI, clientID, secretHash). - Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, - &user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey) + Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application) if errors.Is(err, sql.ErrNoRows) { return ClientUser{}, time.Time{}, ErrNotFound } if err != nil { return ClientUser{}, time.Time{}, err } - if err := json.Unmarshal(permissions, &user.Permissions); err != nil { - return ClientUser{}, time.Time{}, err - } expiresAt := time.Now().UTC().Add(12 * time.Hour) if _, err := tx.ExecContext(ctx, ` INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at) @@ -378,32 +271,18 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) { var user ClientUser - var permissions []byte err := s.db.QueryRowContext(ctx, ` UPDATE client_user_sessions s SET last_used_at = now() - FROM applications a - JOIN tenants t ON t.id = a.tenant_id - JOIN user_application_access ua ON ua.application_id = a.id - JOIN users u ON u.id = ua.user_id - LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id - LEFT JOIN roles r ON r.id = m.role_id + FROM applications a, tenants t, users u, user_application_access ua WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() - AND a.id = s.application_id AND ua.user_id = s.user_id - RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug, - COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`, - tokenHash). - Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, - &user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey) + AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id + AND ua.application_id = a.id AND ua.user_id = u.id + RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`, + tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application) if errors.Is(err, sql.ErrNoRows) { return ClientUser{}, ErrNotFound } - if err != nil { - return ClientUser{}, err - } - if err := json.Unmarshal(permissions, &user.Permissions); err != nil { - return ClientUser{}, err - } - return user, nil + return user, err } func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error { @@ -418,48 +297,3 @@ func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error } return nil } - -// RevokeUserClientSessions ends every hosted-login session belonging to a user and -// reports how many were live. -// -// Go Loose issues the bearer tokens that tenant applications authenticate with, so -// this is what makes signing out here sign you out there: the tokens stop resolving -// immediately, without the other application having to be told or even to ask. It is -// the part of single logout that is enforced here rather than at the identity -// provider. -// -// A user with no sessions is not an error, so the count is returned rather than -// ErrNotFound: logging out twice, or from a session that never used a client -// application, must still succeed. -func (s *Store) RevokeUserClientSessions(ctx context.Context, userID string) (int, error) { - if userID == "" { - return 0, nil - } - result, err := s.db.ExecContext(ctx, ` - UPDATE client_user_sessions SET revoked_at = now() - WHERE user_id = $1 AND revoked_at IS NULL`, userID) - if err != nil { - return 0, err - } - count, err := result.RowsAffected() - if err != nil { - return 0, err - } - return int(count), nil -} - -// CountActiveClientSessions reports how many hosted-login sessions a user holds. -// The account menu uses it to say whether signing out will reach other applications. -func (s *Store) CountActiveClientSessions(ctx context.Context, userID string) (int, error) { - if userID == "" { - return 0, nil - } - var count int - err := s.db.QueryRowContext(ctx, ` - SELECT count(*) FROM client_user_sessions - WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()`, userID).Scan(&count) - if err != nil { - return 0, err - } - return count, nil -} diff --git a/internal/store/installation.go b/internal/store/installation.go index 8e4f989..75f2ff9 100644 --- a/internal/store/installation.go +++ b/internal/store/installation.go @@ -4,15 +4,12 @@ import ( "context" "database/sql" "errors" - - "github.com/danyel/go-loose/internal/role" ) type Installation struct { OIDCIssuer string OIDCClientID string OIDCSecretCiphertext []byte - OIDCRedirectURL string SeededDemo bool } @@ -26,9 +23,9 @@ type DemoUser struct { func (s *Store) Installation(ctx context.Context) (Installation, error) { var installation Installation err := s.db.QueryRowContext(ctx, ` - SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo + SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo FROM installation_settings WHERE singleton = true`). - Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.OIDCRedirectURL, &installation.SeededDemo) + Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.SeededDemo) if errors.Is(err, sql.ErrNoRows) { return Installation{}, ErrNotFound } @@ -42,9 +39,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser } defer tx.Rollback() if _, err := tx.ExecContext(ctx, ` - INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo) - VALUES (true, $1, $2, $3, $4, $5)`, - installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.OIDCRedirectURL, installation.SeededDemo); err != nil { + INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo) + VALUES (true, $1, $2, $3, $4)`, + installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.SeededDemo); err != nil { return err } if len(demoUsers) == 0 { @@ -81,11 +78,8 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser return err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role_id) - SELECT $1, $2, r.id FROM roles r - WHERE r.slug = $3 AND r.tenant_id IS NULL - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`, - tenantID, userID, "admin"); err != nil { + INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin') + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil { return err } if _, err := tx.ExecContext(ctx, ` @@ -98,13 +92,6 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser return tx.Commit() } -// ClaimFirstSystemAdministrator promotes the first user to sign in to system -// administrator and gives them ownership of every tenant that exists at that -// moment. It is a no-op once an administrator exists. -// -// It grants console reach across the installation and nothing more: no -// application grant, so the new administrator cannot sign in to a tenant -// application until one is deliberately given to them. func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -125,41 +112,23 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string return false, err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role_id) - SELECT t.id, $1, r.id FROM tenants t, roles r - WHERE r.slug = 'owner' AND r.tenant_id IS NULL - ON CONFLICT DO NOTHING`, userID); err != nil { + INSERT INTO memberships(tenant_id, user_id, role) + SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO user_application_access(application_id, user_id, granted_by) + SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil { return false, err } - // Deliberately no application grant. Owning every tenant is what lets the - // administrator administer it; it is not a licence to enter the applications - // running inside it. Access to a client application is only ever an explicit - // grant, so an administrator who wants to use one attaches themselves the same - // way anybody else does, visibly, from the console, and it can be withdrawn - // again without touching the tenant. Granting it here instead would hand every - // installation a standing administrator account inside every customer - // application, which is the opposite of the boundary this product draws. return true, tx.Commit() } -// HasConsoleAccess reports whether the user may read the management console. -// System administrators always qualify; otherwise one of their memberships must -// grant console.read, which excludes self-service User members. -func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) { +func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) { var allowed bool err := s.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1) - OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"), - userID, string(role.ReadConsole)).Scan(&allowed) - return allowed, err -} - -// HasMembership reports whether the user belongs to any tenant. A member without -// console access is a self-service user rather than a waiting-room visitor. -func (s *Store) HasMembership(ctx context.Context, userID string) (bool, error) { - var allowed bool - err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID). - Scan(&allowed) + OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed) return allowed, err } @@ -198,11 +167,7 @@ func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([ return result, rows.Err() } -// ApprovePendingUser gives a waiting-room visitor their first membership. -// Only a system administrator may do this, so the escalation guard that -// normally limits role assignment does not apply: the administrator is acting -// across tenants rather than inside one. -func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error { +func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error { isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID) if err != nil { return err @@ -215,17 +180,11 @@ func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantI return err } defer tx.Rollback() - if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil { - return err - } result, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role_id) + INSERT INTO memberships(tenant_id, user_id, role) SELECT $1, id, $3 FROM users WHERE id = $2 - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`, - tenantID, userID, roleID) - if err != nil { - return err - } + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, + tenantID, userID, role) if err != nil { return err } diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go deleted file mode 100644 index 96a124b..0000000 --- a/internal/store/integration_test.go +++ /dev/null @@ -1,669 +0,0 @@ -package store_test - -import ( - "context" - "database/sql" - "errors" - "net/url" - "os" - "strings" - "testing" - - "github.com/danyel/go-loose/internal/contract" - "github.com/danyel/go-loose/internal/database" - "github.com/danyel/go-loose/internal/role" - "github.com/danyel/go-loose/internal/store" -) - -// integrationStore opens the disposable PostgreSQL database named by -// GO_LOOSE_TEST_DATABASE_URL, drops every table, and applies all migrations. The -// test is skipped when the variable is absent so unit runs never touch a -// developer's data. -func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) { - t.Helper() - url := os.Getenv("GO_LOOSE_TEST_DATABASE_URL") - if url == "" { - t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests") - } - requireScratchDatabase(t, url) - ctx := context.Background() - db, err := database.Open(ctx, url) - if err != nil { - t.Fatalf("open database: %v", err) - } - t.Cleanup(func() { db.Close() }) - if err := resetSchema(ctx, db); err != nil { - t.Fatalf("reset schema: %v", err) - } - if err := database.Migrate(ctx, db); err != nil { - t.Fatalf("migrate: %v", err) - } - return store.New(db), db, ctx -} - -// requireScratchDatabase refuses to run against anything that looks like a real -// database. resetSchema drops every table in the public schema, so pointing -// these tests at a development database destroys it. The database name must -// contain "test" unless the operator opts out explicitly. -func requireScratchDatabase(t *testing.T, rawURL string) { - t.Helper() - if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" { - return - } - parsed, err := url.Parse(rawURL) - if err != nil { - t.Fatalf("parse GO_LOOSE_TEST_DATABASE_URL: %v", err) - } - name := strings.TrimPrefix(parsed.Path, "/") - if !strings.Contains(strings.ToLower(name), "test") { - t.Fatalf("refusing to run integration tests against database %q: resetSchema drops every table. "+ - "Point GO_LOOSE_TEST_DATABASE_URL at a scratch database whose name contains \"test\" "+ - "(run `make integration-db` to create one), or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name) - } -} - -// grantApplicationAccess inserts an application grant directly so that tests can -// build states the management API intentionally refuses to create, such as a -// grant for a user with no membership. -func grantApplicationAccess(t *testing.T, db *sql.DB, ctx context.Context, applicationID, userID string) { - t.Helper() - if _, err := db.ExecContext(ctx, ` - INSERT INTO user_application_access(application_id, user_id, granted_by) - VALUES ($1, $2, $2)`, applicationID, userID); err != nil { - t.Fatalf("grant application access: %v", err) - } -} - -func contractDocument() contract.Document { - return contract.Document{ - Version: "3.1.0", - JSON: []byte(`{"openapi":"3.1.0","paths":{"/health":{"get":{"operationId":"health"}}}}`), - Endpoints: []contract.Endpoint{{Method: "GET", Path: "/health", OperationID: "health"}}, - } -} - -// resetSchema drops every table in the public schema, including -// schema_migrations, so that Migrate replays the full history from scratch. -func resetSchema(ctx context.Context, db *sql.DB) error { - rows, err := db.QueryContext(ctx, ` - SELECT tablename FROM pg_tables WHERE schemaname = 'public'`) - if err != nil { - return err - } - defer rows.Close() - tables := make([]string, 0) - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return err - } - tables = append(tables, `"`+name+`"`) - } - if err := rows.Err(); err != nil { - return err - } - for _, name := range tables { - if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+name+" CASCADE"); err != nil { - return err - } - } - return nil -} - -// newUser creates or reuses an account and returns its id. -func newUser(t *testing.T, s *store.Store, ctx context.Context, email string) string { - t.Helper() - user, err := s.UpsertUser(ctx, "test:"+email, email, strings.ToUpper(email[:1])+email[1:]) - if err != nil { - t.Fatalf("upsert %s: %v", email, err) - } - return user.ID -} - -// newTenant creates a tenant owned by owner and registers one application. -func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug string) store.Tenant { - t.Helper() - tenant, err := s.CreateTenant(ctx, owner, slug, strings.ToUpper(slug)) - if err != nil { - t.Fatalf("create tenant %s: %v", slug, err) - } - return tenant -} - -// addMember invites a member into the tenant with the given built-in role. -func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string { - t.Helper() - roleID, err := s.SystemRoleID(ctx, membershipRole) - if err != nil { - t.Fatalf("look up role %q: %v", membershipRole, err) - } - member, err := s.InviteUser(ctx, owner, tenantID, email, email, roleID, "not-a-real-hash") - if err != nil { - t.Fatalf("invite %s as %s: %v", email, membershipRole, err) - } - if member.Role != membershipRole { - t.Fatalf("invited role = %q, want %q", member.Role, membershipRole) - } - return member.ID -} - -func TestMigrationsApplyAndRolesAreAccepted(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - for _, item := range []string{"developer", "operator", "user"} { - addMember(t, s, ctx, owner, tenant.ID, item+"@example.test", item) - } - if _, err := s.ListTenants(ctx, owner); err != nil { - t.Fatalf("list tenants: %v", err) - } -} - -func TestUnknownRoleIDIsRefused(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer") - if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "00000000-0000-0000-0000-000000000000", nil); err == nil { - t.Fatal("assigning a role that does not exist must be refused") - } -} - -func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - application, err := s.CreateApplication(ctx, owner, store.Application{ - TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{}, - }) - if err != nil { - t.Fatalf("owner cannot create an application: %v", err) - } - tests := []struct { - role string - canCreateApp bool - canIssueKey bool - canSaveContract bool - canListManaged bool - canConfigureClien bool - }{ - // owner and admin administer members; developer and operator may also - // maintain their own tenant's members, which is why they can list them. - {role: "owner", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, - {role: "admin", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, - {role: "developer", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, - {role: "operator", canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: false}, - {role: "viewer", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false}, - {role: "user", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false}, - } - for _, test := range tests { - t.Run(test.role, func(t *testing.T) { - member := addMember(t, s, ctx, owner, tenant.ID, test.role+"@example.test", test.role) - slug := strings.ReplaceAll(test.role, "owner", "own") - - _, err := s.CreateApplication(ctx, member, store.Application{ - TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{}, - }) - assertPermission(t, "create application", err, test.canCreateApp) - - _, err = s.CreateAPIKey(ctx, member, application.ID, slug+"-key", "gl_prefix", secretHash(slug), nil) - assertPermission(t, "issue key", err, test.canIssueKey) - - _, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil) - assertPermission(t, "import contract", err, test.canSaveContract) - - managed, err := s.ListManagedUsers(ctx, member) - if err != nil { - t.Fatalf("list managed users: %v", err) - } - if test.canListManaged != (len(managed) > 0) { - t.Fatalf("users.read for %s: got %d members, want non-empty=%v", test.role, len(managed), test.canListManaged) - } - - _, err = s.ConfigureClient(ctx, member, application.ID, []string{"https://app.example.test/callback"}, secretHash(slug)) - assertPermission(t, "configure client login", err, test.canConfigureClien) - }) - } -} - -func TestConsoleAccessExcludesSelfServiceMembers(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", "user") - operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", "operator") - waiting := newUser(t, s, ctx, "waiting@example.test") - - for _, test := range []struct { - name string - userID string - console bool - member bool - }{ - {name: "owner", userID: owner, console: true, member: true}, - {name: "operator", userID: operator, console: true, member: true}, - {name: "self-service user", userID: selfService, console: false, member: true}, - {name: "waiting room", userID: waiting, console: false, member: false}, - } { - t.Run(test.name, func(t *testing.T) { - console, err := s.HasConsoleAccess(ctx, test.userID) - if err != nil { - t.Fatalf("HasConsoleAccess: %v", err) - } - if console != test.console { - t.Fatalf("HasConsoleAccess = %v, want %v", console, test.console) - } - member, err := s.HasMembership(ctx, test.userID) - if err != nil { - t.Fatalf("HasMembership: %v", err) - } - if member != test.member { - t.Fatalf("HasMembership = %v, want %v", member, test.member) - } - }) - } -} - -func TestTenantPermissionsComeFromTheRoleMatrix(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", "developer") - - tenants, err := s.ListTenants(ctx, developer) - if err != nil { - t.Fatalf("list tenants: %v", err) - } - if len(tenants) != 1 || tenants[0].Role != "developer" { - t.Fatalf("tenants = %+v", tenants) - } - if !contains(tenants[0].Permissions, string(role.ManageApplications)) { - t.Fatalf("developer permissions = %v", tenants[0].Permissions) - } - // developer administers members but must not mint roles, which is what - // defines privilege in the first place. - if !contains(tenants[0].Permissions, string(role.ManageUsers)) { - t.Fatalf("developer permissions = %v, want users.manage", tenants[0].Permissions) - } - if contains(tenants[0].Permissions, string(role.ManageRoles)) { - t.Fatalf("developer permissions = %v, must not manage roles", tenants[0].Permissions) - } -} - -func TestProfileRoundTrip(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator") - - before, err := s.Profile(ctx, member) - if err != nil { - t.Fatalf("profile: %v", err) - } - if before.AvatarKey != nil || before.NameCustomized { - t.Fatalf("fresh profile = %+v", before) - } - if len(before.Memberships) != 1 || before.Memberships[0].Role != "operator" { - t.Fatalf("memberships = %+v", before.Memberships) - } - if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) { - t.Fatalf("membership permissions = %v", before.Memberships[0].Permissions) - } - - name := "Renamed By User" - if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{DisplayName: &name}); err != nil { - t.Fatalf("rename: %v", err) - } - after, err := s.Profile(ctx, member) - if err != nil { - t.Fatalf("profile after rename: %v", err) - } - if after.DisplayName != name || !after.NameCustomized || after.ProfileUpdatedAt == nil { - t.Fatalf("profile = %+v", after) - } -} - -func TestLoginDoesNotOverwriteACustomizedDisplayName(t *testing.T) { - s, _, ctx := integrationStore(t) - user := newUser(t, s, ctx, "person@example.test") - name := "My Chosen Name" - if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{DisplayName: &name}); err != nil { - t.Fatalf("rename: %v", err) - } - for range 2 { - if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil { - t.Fatalf("upsert: %v", err) - } - } - profile, err := s.Profile(ctx, user) - if err != nil { - t.Fatalf("profile: %v", err) - } - if profile.DisplayName != name { - t.Fatalf("display name = %q, want %q", profile.DisplayName, name) - } -} - -func TestUncustomizedDisplayNameStillFollowsTheIdentityProvider(t *testing.T) { - s, _, ctx := integrationStore(t) - user := newUser(t, s, ctx, "person@example.test") - if _, err := s.UpsertUser(ctx, "google:1234", "person@example.test", "Identity Provider Name"); err != nil { - t.Fatalf("upsert: %v", err) - } - profile, err := s.Profile(ctx, user) - if err != nil { - t.Fatalf("profile: %v", err) - } - if profile.DisplayName != "Identity Provider Name" { - t.Fatalf("display name = %q", profile.DisplayName) - } -} - -func TestPictureIsStoredAndReadableByKey(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer") - key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl" - payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4} - - if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{ - AvatarKey: key, AvatarData: payload, ContentType: "image/png", - }); err != nil { - t.Fatalf("store picture: %v", err) - } - picture, err := s.Picture(ctx, key) - if err != nil { - t.Fatalf("load picture: %v", err) - } - if picture.ContentType != "image/png" || string(picture.Data) != string(payload) { - t.Fatalf("picture = %+v", picture) - } - if picture.UpdatedAt.IsZero() { - t.Fatal("picture must record when it was stored") - } - profile, err := s.Profile(ctx, member) - if err != nil { - t.Fatalf("profile: %v", err) - } - if profile.AvatarKey == nil || *profile.AvatarKey != key || profile.AvatarUpdatedAt == nil { - t.Fatalf("profile avatar = %+v", profile) - } - - if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{RemoveAvatar: true}); err != nil { - t.Fatalf("remove picture: %v", err) - } - if _, err := s.Picture(ctx, key); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("Picture() error = %v, want ErrNotFound", err) - } - cleared, err := s.Profile(ctx, member) - if err != nil { - t.Fatalf("profile: %v", err) - } - if cleared.AvatarKey != nil || cleared.AvatarUpdatedAt != nil { - t.Fatalf("profile still advertises a picture: %+v", cleared) - } -} - -func TestReplacingAPictureRetiresTheOldKey(t *testing.T) { - s, _, ctx := integrationStore(t) - user := newUser(t, s, ctx, "person@example.test") - first := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl" - second := "b3JkZXJ5a2V5Zm9ydGhlU2Vjb25kVXBvZGF0ZVRlc3QxMjM0NQ" - if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: first, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil { - t.Fatalf("store first picture: %v", err) - } - if err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: second, AvatarData: []byte("b"), ContentType: "image/png"}); err != nil { - t.Fatalf("store second picture: %v", err) - } - if _, err := s.Picture(ctx, first); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("the replaced key must stop resolving, got %v", err) - } - picture, err := s.Picture(ctx, second) - if err != nil || string(picture.Data) != "b" { - t.Fatalf("picture = %+v, err = %v", picture, err) - } -} - -func TestPictureKeysAreUnique(t *testing.T) { - s, _, ctx := integrationStore(t) - first, second := newUser(t, s, ctx, "one@example.test"), newUser(t, s, ctx, "two@example.test") - key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl" - if err := s.UpdateProfile(ctx, first, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("a"), ContentType: "image/png"}); err != nil { - t.Fatalf("store first picture: %v", err) - } - if err := s.UpdateProfile(ctx, second, store.ProfileUpdate{AvatarKey: key, AvatarData: []byte("b"), ContentType: "image/png"}); err == nil { - t.Fatal("two users must not be able to claim the same picture key") - } -} - -func TestIncompletePictureIsRejectedByTheDatabase(t *testing.T) { - s, _, ctx := integrationStore(t) - user := newUser(t, s, ctx, "person@example.test") - err := s.UpdateProfile(ctx, user, store.ProfileUpdate{AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"}) - if err != nil { - t.Fatalf("an update without picture bytes is a no-op, got %v", err) - } - if _, err := s.Picture(ctx, "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("Picture() error = %v, want ErrNotFound", err) - } -} - -func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) { - s, db, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - application, err := s.CreateApplication(ctx, owner, store.Application{ - TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{}, - }) - if err != nil { - t.Fatalf("create application: %v", err) - } - member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator") - if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil { - t.Fatalf("configure client: %v", err) - } - if err := s.UpdateProfile(ctx, member, store.ProfileUpdate{ - AvatarKey: "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl", - AvatarData: []byte("a"), ContentType: "image/png", - }); err != nil { - t.Fatalf("store picture: %v", err) - } - grantApplicationAccess(t, db, ctx, application.ID, member) - if err := s.CreateAuthorizationCode(ctx, application.ID, member, "https://app.example.test/callback", []byte("code-hash")); err != nil { - t.Fatalf("create authorization code: %v", err) - } - user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/callback", []byte("hash"), []byte("code-hash"), []byte("token-hash")) - if err != nil { - t.Fatalf("exchange authorization code: %v", err) - } - if user.Role != "operator" { - t.Fatalf("role = %q, want operator", user.Role) - } - if user.AvatarKey == nil { - t.Fatal("the client payload must expose the picture locator") - } - byToken, err := s.ClientUserByToken(ctx, []byte("token-hash")) - if err != nil { - t.Fatalf("client user by token: %v", err) - } - if byToken.Role != "operator" || byToken.AvatarKey == nil { - t.Fatalf("client user = %+v", byToken) - } -} - -func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) { - s, db, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs") - application, err := s.CreateApplication(ctx, owner, store.Application{ - TenantID: tenant.ID, Slug: "guess", Name: "Guess", AllowedHosts: []string{}, - }) - if err != nil { - t.Fatalf("create application: %v", err) - } - orphan := newUser(t, s, ctx, "orphan@example.test") - if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/cb"}, []byte("hash")); err != nil { - t.Fatalf("configure client: %v", err) - } - grantApplicationAccess(t, db, ctx, application.ID, orphan) - if err := s.CreateAuthorizationCode(ctx, application.ID, orphan, "https://app.example.test/cb", []byte("code")); err != nil { - t.Fatalf("create authorization code: %v", err) - } - user, _, err := s.ExchangeAuthorizationCode(ctx, application.ClientID, "https://app.example.test/cb", []byte("hash"), []byte("code"), []byte("token")) - if err != nil { - t.Fatalf("exchange authorization code: %v", err) - } - if user.Role != "user" { - t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, "user") - } -} - -func assertPermission(t *testing.T, operation string, err error, allowed bool) { - t.Helper() - switch { - case allowed && err != nil: - t.Fatalf("%s must be allowed, got %v", operation, err) - case !allowed && !errors.Is(err, store.ErrNotFound): - t.Fatalf("%s must be denied with ErrNotFound, got %v", operation, err) - } -} - -// secretHash builds a distinct digest per key name because secret_hash is unique. -func secretHash(name string) []byte { - return []byte("sha256-of-" + name) -} - -func contains(values []string, want string) bool { - for _, item := range values { - if item == want { - return true - } - } - return false -} - -// TestBootstrapGrantsOwnershipOnlyWhileTheTenantHasNone pins the rule that a -// bootstrapped account becomes a tenant's owner only when nobody owns it yet. The -// installer's demonstration seed creates tenants that already hold members, and -// refusing ownership there used to leave a local administrator with an account -// that could never reach the console. -func TestBootstrapGrantsOwnershipOnlyWhileTheTenantHasNone(t *testing.T) { - s, db, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "bootstrap") - // Leave the tenant holding administrators but no owner, which is the state the - // installer's demonstration seed produces. - adminRole, err := s.SystemRoleID(ctx, "admin") - if err != nil { - t.Fatalf("look up the admin role: %v", err) - } - if _, err := db.ExecContext(ctx, - `UPDATE memberships SET role_id = $1 WHERE tenant_id = $2`, adminRole, tenant.ID); err != nil { - t.Fatalf("demote the owner: %v", err) - } - addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "admin") - late := newUser(t, s, ctx, "late@example.test") - - if err := s.Bootstrap(ctx, late, tenant.Slug, "guess"); err != nil { - t.Fatalf("bootstrap into a tenant with no owner: %v", err) - } - assertRole(t, db, ctx, late, tenant.ID, "owner") - - // A further account must not displace the owner that now exists. - third := newUser(t, s, ctx, "third@example.test") - if err := s.Bootstrap(ctx, third, tenant.Slug, "guess"); err != nil { - t.Fatalf("bootstrap again: %v", err) - } - var memberships int - if err := db.QueryRowContext(ctx, ` - SELECT count(*) FROM memberships m - JOIN roles r ON r.id = m.role_id - WHERE m.tenant_id = $1 AND r.slug = 'owner'`, tenant.ID).Scan(&memberships); err != nil { - t.Fatalf("count owners: %v", err) - } - if memberships != 1 { - t.Errorf("owners = %d, want exactly one", memberships) - } - var thirdMemberships int - if err := db.QueryRowContext(ctx, - `SELECT count(*) FROM memberships WHERE user_id = $1`, third).Scan(&thirdMemberships); err != nil { - t.Fatalf("count memberships: %v", err) - } - if thirdMemberships != 0 { - t.Errorf("a later bootstrap joined a tenant that already had an owner") - } -} - -func assertRole(t *testing.T, db *sql.DB, ctx context.Context, userID, tenantID, want string) { - t.Helper() - var got string - err := db.QueryRowContext(ctx, ` - SELECT r.slug FROM memberships m JOIN roles r ON r.id = m.role_id - WHERE m.user_id = $1 AND m.tenant_id = $2`, userID, tenantID).Scan(&got) - if err != nil { - t.Fatalf("read membership: %v", err) - } - if got != want { - t.Errorf("role = %q, want %q", got, want) - } -} - -// TestCreateTenantReportsADuplicateSlug pins that a slug already in use is a -// conflict the caller can act on rather than an opaque server error. -func TestCreateTenantReportsADuplicateSlug(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - newTenant(t, s, ctx, owner, "nmbs") - if _, err := s.CreateTenant(ctx, owner, "nmbs", "Duplicate"); !errors.Is(err, store.ErrTenantSlugTaken) { - t.Fatalf("CreateTenant with a used slug = %v, want ErrTenantSlugTaken", err) - } -} - -// TestConcurrentMigrationRunsDoNotCollide pins that instances starting together -// against an empty database all succeed. CREATE TABLE IF NOT EXISTS is not race -// free in PostgreSQL, so the run is serialised with an advisory lock. -func TestConcurrentMigrationRunsDoNotCollide(t *testing.T) { - databaseURL := os.Getenv("GO_LOOSE_TEST_DATABASE_URL") - if databaseURL == "" { - t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests") - } - requireScratchDatabase(t, databaseURL) - resetSchema(context.Background(), mustOpen(t, databaseURL)) - - const instances = 4 - connections := make([]*sql.DB, instances) - for index := range connections { - connections[index] = mustOpen(t, databaseURL) - } - errs := make(chan error, instances) - for _, connection := range connections { - go func(conn *sql.DB) { errs <- database.Migrate(context.Background(), conn) }(connection) - } - for range connections { - if err := <-errs; err != nil { - t.Errorf("concurrent Migrate: %v", err) - } - } - for _, connection := range connections { - var applied int - if err := connection.QueryRowContext(context.Background(), - `SELECT count(*) FROM schema_migrations`).Scan(&applied); err != nil { - t.Errorf("count migrations: %v", err) - continue - } - if applied == 0 { - t.Error("no migration was recorded") - } - } -} - -func mustOpen(t *testing.T, databaseURL string) *sql.DB { - t.Helper() - db, err := database.Open(context.Background(), databaseURL) - if err != nil { - t.Fatalf("open database: %v", err) - } - t.Cleanup(func() { db.Close() }) - return db -} diff --git a/internal/store/passwordauth.go b/internal/store/passwordauth.go index f2f0132..9727ab8 100644 --- a/internal/store/passwordauth.go +++ b/internal/store/passwordauth.go @@ -6,16 +6,6 @@ import ( "errors" ) -// EnsurePasswordUser creates or updates the local account behind -// GO_LOOSE_LOCAL_ADMIN_PASSWORD and bootstraps it into the installation. -// -// The local administrator is operator-supplied rather than created by a visitor, -// so when the installation has no system administrator yet it claims the role. -// Without that, a database-only deployment would have nobody able to create -// tenants or approve waiting-room users, because both require the role that only -// a first OIDC sign-in otherwise grants. The claim is a no-op once any system -// administrator exists, so an installation already owned by a Google account is -// left alone. func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error { user, err := s.UpsertUser(ctx, "local:"+email, email, displayName) if err != nil { @@ -27,9 +17,6 @@ func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, pass WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil { return err } - if _, err := s.ClaimFirstSystemAdministrator(ctx, user.ID); err != nil { - return err - } return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug) } diff --git a/internal/store/permissions.go b/internal/store/permissions.go deleted file mode 100644 index f2738ad..0000000 --- a/internal/store/permissions.go +++ /dev/null @@ -1,224 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "errors" - "fmt" - "regexp" - "slices" - "strings" - - "github.com/danyel/go-loose/internal/role" -) - -// Permission is one entry in the installation-wide catalog. The nine seeded by -// migration 0007 are the capabilities the console enforces; anything added later -// is grantable and reported to client applications but is not checked here, -// because nothing in the request path tests for it. -type Permission struct { - Name string `json:"name"` - Label string `json:"label"` - Description string `json:"description"` - System bool `json:"system"` - // RoleCount is how many roles currently grant it. A permission that no role - // grants can be removed again. - RoleCount int `json:"role_count"` - // Enforced reports whether the application itself checks for this - // permission. The console surfaces this so an operator is not misled into - // believing a custom capability is enforced. - Enforced bool `json:"enforced"` -} - -var ( - // ErrPermissionInUse is returned when a permission a role still grants is - // deleted. - ErrPermissionInUse = errors.New("permission is still granted by a role") - // ErrPermissionTaken is returned when a permission name already exists. - ErrPermissionTaken = errors.New("permission name is already in use") - // ErrPermissionSystem is returned when a built-in permission would be edited - // or deleted. - ErrPermissionSystem = errors.New("built-in permissions cannot be changed") -) - -// permissionName constrains a name to lowercase dotted words such as -// "reports.export". The database enforces the same shape. -var permissionName = regexp.MustCompile(`^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$`) - -// ValidPermissionName reports whether a name is well formed. -func ValidPermissionName(name string) bool { - return len(name) >= 3 && len(name) <= 100 && permissionName.MatchString(name) -} - -// ListPermissions returns the whole catalog, most sensitive first for the -// built-ins and alphabetically after them. -func (s *Store) ListPermissions(ctx context.Context) ([]Permission, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT p.name, p.label, p.description, p.is_system, - (SELECT count(*) FROM role_permissions rp WHERE rp.permission = p.name) - FROM permissions p - ORDER BY array_position(`+permissionOrder+`, p.name) NULLS LAST, p.name`) - if err != nil { - return nil, err - } - defer rows.Close() - result := make([]Permission, 0) - for rows.Next() { - var item Permission - if err := rows.Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount); err != nil { - return nil, err - } - item.Enforced = role.Permission(item.Name).Valid() - result = append(result, item) - } - return result, rows.Err() -} - -// CreatePermission adds a name to the catalog so that roles can grant it. -// Requires either a system administrator or tenants.manage, because the catalog -// is shared by every tenant rather than scoped to one. -func (s *Store) CreatePermission(ctx context.Context, actorID, name, label, description string) (Permission, error) { - name = strings.TrimSpace(name) - label = strings.TrimSpace(label) - description = strings.TrimSpace(description) - if !ValidPermissionName(name) { - return Permission{}, fmt.Errorf("%w: use lowercase words separated by dots", ErrUnknownPermission) - } - if label == "" || len(label) > 100 { - return Permission{}, fmt.Errorf("%w: label must be between 1 and 100 characters", ErrUnknownPermission) - } - if len(description) > 500 { - return Permission{}, fmt.Errorf("%w: description must be 500 characters or fewer", ErrUnknownPermission) - } - if role.Permission(name).Valid() { - // The name is one the application enforces; it is already seeded, so this - // can only be a duplicate. - return Permission{}, ErrPermissionTaken - } - permitted, err := s.mayAdministerCatalog(ctx, actorID) - if err != nil { - return Permission{}, err - } - if !permitted { - return Permission{}, ErrNotFound - } - item := Permission{Name: name, Label: label, Description: description, Enforced: false} - err = s.db.QueryRowContext(ctx, ` - INSERT INTO permissions(name, label, description) - VALUES ($1, $2, $3) - RETURNING name, label, description, is_system, - (SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)`, - name, label, description). - Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount) - if isUniqueViolation(err) { - return Permission{}, ErrPermissionTaken - } - if err != nil { - return Permission{}, err - } - return item, nil -} - -// DeletePermission removes a catalog entry that no role grants. Built-in -// permissions are kept: the console enforces them and dropping one would leave a -// membership holding a capability nothing recognizes. -func (s *Store) DeletePermission(ctx context.Context, actorID, name string) error { - permitted, err := s.mayAdministerCatalog(ctx, actorID) - if err != nil { - return err - } - if !permitted { - return ErrNotFound - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - var ( - label string - system bool - grants int - ) - err = tx.QueryRowContext(ctx, ` - SELECT label, is_system, - (SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name) - FROM permissions WHERE name = $1 FOR UPDATE`, name). - Scan(&label, &system, &grants) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if system { - return ErrPermissionSystem - } - if grants > 0 { - return ErrPermissionInUse - } - if _, err := tx.ExecContext(ctx, `DELETE FROM permissions WHERE name = $1`, name); err != nil { - return err - } - return tx.Commit() -} - -// mayAdministerCatalog reports whether the actor may change the shared catalog. -// The catalog is installation-wide, so this is a system administrator or an owner -// of some tenant, which is the permission that already means "administer the -// installation". -func (s *Store) mayAdministerCatalog(ctx context.Context, actorID string) (bool, error) { - return mayAdministerCatalog(ctx, s.db, actorID) -} - -func mayAdministerCatalog(ctx context.Context, q querier, actorID string) (bool, error) { - var permitted bool - err := q.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1) - OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"), - actorID, string(role.ManageTenants)).Scan(&permitted) - return permitted, err -} - -// grantablePermissions returns what an actor may hand to somebody: the -// permissions they hold, plus -- for whoever administers the shared catalog -- -// every custom permission. -// -// The second half is what makes the catalog usable. A permission nobody holds -// yet could not otherwise be granted, because the escalation guard refuses to -// hand out a capability the granter does not have. Letting the author of a -// permission pass it on resolves that without weakening the guard for enforced -// permissions, which are never in the custom set. -func grantablePermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) { - held, err := heldPermissions(ctx, q, actorID, tenantID) - if err != nil { - return nil, err - } - administers, err := mayAdministerCatalog(ctx, q, actorID) - if err != nil || !administers { - return held, err - } - rows, err := q.QueryContext(ctx, `SELECT name FROM permissions WHERE NOT is_system`) - if err != nil { - return nil, err - } - defer rows.Close() - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, err - } - if !slices.Contains(held, name) { - held = append(held, name) - } - } - return held, rows.Err() -} - -// permissionOrder lists the enforced capabilities so the catalog keeps the same -// most-sensitive-first order the role editor used before it was a table. -var permissionOrder = `ARRAY[ - 'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage', - 'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit' -]` diff --git a/internal/store/permissions_integration_test.go b/internal/store/permissions_integration_test.go deleted file mode 100644 index 58679f7..0000000 --- a/internal/store/permissions_integration_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "testing" - - "github.com/danyel/go-loose/internal/role" - "github.com/danyel/go-loose/internal/store" -) - -func TestCatalogSeedsTheEnforcedPermissions(t *testing.T) { - s, _, ctx := integrationStore(t) - catalog, err := s.ListPermissions(ctx) - if err != nil { - t.Fatalf("list permissions: %v", err) - } - if len(catalog) != len(role.Permissions()) { - t.Fatalf("catalog has %d entries, want the %d seeded permissions", len(catalog), len(role.Permissions())) - } - for _, item := range catalog { - if !item.System { - t.Errorf("%q was not marked built in", item.Name) - } - if !item.Enforced { - t.Errorf("%q is seeded but not reported as enforced", item.Name) - } - if item.Label == "" { - t.Errorf("%q has no label", item.Name) - } - } - // The order the role editor renders is most sensitive first. - if catalog[0].Name != string(role.ManageTenants) { - t.Errorf("catalog starts with %q, want %q", catalog[0].Name, role.ManageTenants) - } -} - -func TestTenantCanAddAPermissionAndGrantIt(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs").ID - - created, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", "Lets an app export reports.") - if err != nil { - t.Fatalf("create permission: %v", err) - } - if created.System { - t.Error("a permission added through the API must not be marked built in") - } - if created.Enforced { - t.Error("a custom permission cannot be enforced by the console") - } - - // It is immediately grantable, exactly like a built-in one. - if _, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "", - []string{string(role.ReadConsole), "reports.export"}); err != nil { - t.Fatalf("grant a custom permission: %v", err) - } - - catalog, err := s.ListPermissions(ctx) - if err != nil { - t.Fatalf("list permissions: %v", err) - } - found := false - for _, item := range catalog { - if item.Name == "reports.export" { - found = true - if item.RoleCount != 1 { - t.Errorf("role_count = %d, want 1", item.RoleCount) - } - } - } - if !found { - t.Fatal("the new permission is missing from the catalog") - } -} - -func TestDeletingAPermissionInUseIsRefused(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - tenant := newTenant(t, s, ctx, owner, "nmbs").ID - - if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil { - t.Fatalf("create permission: %v", err) - } - reporter, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "", - []string{string(role.ReadConsole), "reports.export"}) - if err != nil { - t.Fatalf("create role: %v", err) - } - if err := s.DeletePermission(ctx, owner, "reports.export"); !errors.Is(err, store.ErrPermissionInUse) { - t.Fatalf("delete a granted permission = %v, want ErrPermissionInUse", err) - } - - // Once no role grants it, the delete goes through. - if _, err := s.UpdateRole(ctx, owner, reporter.ID, "Reporter", "", - []string{string(role.ReadConsole)}); err != nil { - t.Fatalf("drop the custom permission: %v", err) - } - if err := s.DeletePermission(ctx, owner, "reports.export"); err != nil { - t.Fatalf("delete an unused permission: %v", err) - } -} - -func TestBuiltInPermissionsCannotBeDeleted(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - newTenant(t, s, ctx, owner, "nmbs") - if err := s.DeletePermission(ctx, owner, string(role.ManageUsers)); !errors.Is(err, store.ErrPermissionSystem) { - t.Fatalf("delete a built-in permission = %v, want ErrPermissionSystem", err) - } -} - -func TestDuplicateAndMalformedPermissionsAreRejected(t *testing.T) { - s, _, ctx := integrationStore(t) - owner := newUser(t, s, ctx, "owner@example.test") - newTenant(t, s, ctx, owner, "nmbs") - - if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export", ""); err != nil { - t.Fatalf("create permission: %v", err) - } - if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export again", ""); !errors.Is(err, store.ErrPermissionTaken) { - t.Fatalf("duplicate permission = %v, want ErrPermissionTaken", err) - } - // A name the console already enforces cannot be redefined. - if _, err := s.CreatePermission(ctx, owner, string(role.ManageUsers), "Manage", ""); !errors.Is(err, store.ErrPermissionTaken) { - t.Fatalf("redefining a built-in = %v, want ErrPermissionTaken", err) - } - for _, name := range []string{"Reports.Export", "reports export", ".reports", "reports.", "", "ab"} { - if _, err := s.CreatePermission(ctx, owner, name, "Label", ""); err == nil { - t.Errorf("CreatePermission(%q) was accepted", name) - } - } -} - -// TestOnlyOwnersMayChangeTheSharedCatalog guards the fact that the catalog is -// installation-wide: a developer who may maintain their own tenant's members -// must not be able to add a capability every tenant can then grant. -func TestOnlyOwnersMayChangeTheSharedCatalog(t *testing.T) { - s, ctx, _, developer, _ := rolesFixture(t) - if _, err := s.CreatePermission(ctx, developer, "reports.export", "Export reports", ""); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer adding a permission = %v, want ErrNotFound", err) - } - if err := s.DeletePermission(ctx, developer, string(role.ReadConsole)); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer deleting a permission = %v, want ErrNotFound", err) - } -} - -func TestARoleCannotGrantAPermissionThatIsNotInTheCatalog(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - _, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "", - []string{string(role.ReadConsole), "not.in.the.catalog"}) - if !errors.Is(err, store.ErrUnknownPermission) { - t.Fatalf("granting an unknown permission = %v, want ErrUnknownPermission", err) - } - // Nor can an existing role be edited to hold one. - if _, err := s.UpdateRole(ctx, owner, mustSystemRole(t, s, ctx, "viewer"), "Viewer", "", - []string{string(role.ReadConsole), "not.in.the.catalog"}); !errors.Is(err, store.ErrUnknownPermission) { - t.Fatalf("editing in an unknown permission = %v, want ErrUnknownPermission", err) - } -} - -// TestGrantingACustomPermissionNeedsTheRightAuthor documents the rule that makes -// the catalog usable without weakening the escalation guard. Whoever administers -// the catalog authored the permission, so they may pass it on even though nobody -// holds it yet; anybody else still needs to hold it first. -func TestGrantingACustomPermissionNeedsTheRightAuthor(t *testing.T) { - s, ctx, owner, developer, tenantID := rolesFixture(t) - if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil { - t.Fatalf("create permission: %v", err) - } - - // The owner administers the catalog, so they can define a role granting it. - // The role also carries users.manage so that whoever ends up holding it is - // actually able to assign roles to somebody else. - created, err := s.CreateRole(ctx, owner, tenantID, "reporter", "Reporter", "", - []string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"}) - if err != nil { - t.Fatalf("owner granting a custom permission: %v", err) - } - - // A developer administers members but not the catalog, and does not hold the - // permission, so they may not hand the role out. - member := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer") - if err := s.SetUserAccess(ctx, developer, tenantID, member, created.ID, nil); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer assigning a role carrying an unheld custom permission = %v, want ErrNotFound", err) - } - // Nor may they mint an equivalent role of their own. - if _, err := s.CreateRole(ctx, developer, tenantID, "rogue", "Rogue", "", - []string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"}); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer minting a custom permission = %v, want ErrNotFound", err) - } - // A member who does hold it, and may manage members, may pass it on. - reporter := addMember(t, s, ctx, owner, tenantID, "reporter@example.test", "viewer") - if err := s.SetUserAccess(ctx, owner, tenantID, reporter, created.ID, nil); err != nil { - t.Fatalf("owner assigning the role: %v", err) - } - if err := s.SetUserAccess(ctx, reporter, tenantID, member, created.ID, nil); err != nil { - t.Fatalf("holder passing the permission on: %v", err) - } - - // Holding the permission is not enough on its own: a role without - // users.manage cannot assign anything. - readonly, err := s.CreateRole(ctx, owner, tenantID, "readonly-reporter", "Read only reporter", "", - []string{string(role.ReadConsole), "reports.export"}) - if err != nil { - t.Fatalf("create read-only role: %v", err) - } - if err := s.SetUserAccess(ctx, owner, tenantID, reporter, mustSystemRole(t, s, ctx, "viewer"), nil); err != nil { - t.Fatalf("move the reporter aside: %v", err) - } - if err := s.SetUserAccess(ctx, reporter, tenantID, member, readonly.ID, nil); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("assigning without users.manage = %v, want ErrNotFound", err) - } -} - -func mustSystemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string { - t.Helper() - id, err := s.SystemRoleID(ctx, slug) - if err != nil { - t.Fatalf("look up %q: %v", slug, err) - } - return id -} diff --git a/internal/store/profile.go b/internal/store/profile.go deleted file mode 100644 index 7f4e769..0000000 --- a/internal/store/profile.go +++ /dev/null @@ -1,159 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "time" -) - -// Membership is one tenant role held by a user. -type Membership struct { - TenantID string `json:"tenant_id"` - TenantSlug string `json:"tenant_slug"` - TenantName string `json:"tenant_name"` - Role string `json:"role"` - RoleID string `json:"role_id"` - Permissions []string `json:"permissions"` -} - -// Profile is a user's own account information. AvatarKey is the stored locator; -// the server expands it into an absolute AvatarURL. -type Profile struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - AvatarKey *string `json:"-"` - AvatarURL string `json:"avatar_url"` - Memberships []Membership `json:"memberships"` - IsSystemAdministrator bool `json:"is_system_administrator"` - NameCustomized bool `json:"name_customized"` - CreatedAt time.Time `json:"created_at"` - LastLoginAt time.Time `json:"last_login_at"` - ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"` - AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"` -} - -// ProfileUpdate carries a self-service profile change. A nil DisplayName leaves -// the display name untouched. Avatar changes are mutually exclusive: Remove -// clears the picture, otherwise Data and ContentType replace it. -type ProfileUpdate struct { - DisplayName *string - RemoveAvatar bool - AvatarKey string - AvatarData []byte - ContentType string -} - -// Picture is a stored profile picture. -type Picture struct { - Data []byte - ContentType string - UpdatedAt time.Time -} - -// Profile loads a user together with every tenant role they hold. -func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) { - var profile Profile - var avatarKey sql.NullString - err := s.db.QueryRowContext(ctx, ` - SELECT u.id, u.email, u.display_name, u.avatar_key, u.name_customized, - u.created_at, u.last_login_at, u.profile_updated_at, u.avatar_updated_at, - EXISTS(SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id) - FROM users u - WHERE u.id = $1`, userID). - Scan(&profile.ID, &profile.Email, &profile.DisplayName, &avatarKey, &profile.NameCustomized, - &profile.CreatedAt, &profile.LastLoginAt, &profile.ProfileUpdatedAt, - &profile.AvatarUpdatedAt, &profile.IsSystemAdministrator) - if errors.Is(err, sql.ErrNoRows) { - return Profile{}, ErrNotFound - } - if err != nil { - return Profile{}, err - } - if avatarKey.Valid { - profile.AvatarKey = &avatarKey.String - } - profile.Memberships = []Membership{} - rows, err := s.db.QueryContext(ctx, ` - SELECT m.tenant_id, t.slug, t.name, r.slug, m.role_id, - COALESCE(( - SELECT json_agg(rp.permission ORDER BY rp.permission) - FROM role_permissions rp WHERE rp.role_id = m.role_id - ), '[]'::json) - FROM memberships m - JOIN tenants t ON t.id = m.tenant_id - JOIN roles r ON r.id = m.role_id - WHERE m.user_id = $1 - ORDER BY t.name`, userID) - if err != nil { - return Profile{}, err - } - defer rows.Close() - for rows.Next() { - var item Membership - var permissions []byte - if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role, - &item.RoleID, &permissions); err != nil { - return Profile{}, err - } - if err := json.Unmarshal(permissions, &item.Permissions); err != nil { - return Profile{}, err - } - profile.Memberships = append(profile.Memberships, item) - } - return profile, rows.Err() -} - -// UpdateProfile applies a self-service profile change. The user is identified by -// their own id, so no membership check is required: every account may edit its -// own display name and picture regardless of role. -func (s *Store) UpdateProfile(ctx context.Context, userID string, update ProfileUpdate) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - if update.DisplayName != nil { - if _, err := tx.ExecContext(ctx, ` - UPDATE users - SET display_name = $2, name_customized = true, profile_updated_at = now() - WHERE id = $1`, userID, *update.DisplayName); err != nil { - return err - } - } - switch { - case update.RemoveAvatar: - if _, err := tx.ExecContext(ctx, ` - UPDATE users - SET avatar = NULL, avatar_key = NULL, avatar_content_type = NULL, - avatar_updated_at = NULL, profile_updated_at = now() - WHERE id = $1`, userID); err != nil { - return err - } - case len(update.AvatarData) > 0: - if _, err := tx.ExecContext(ctx, ` - UPDATE users - SET avatar = $2, avatar_key = $3, avatar_content_type = $4, - avatar_updated_at = now(), profile_updated_at = now() - WHERE id = $1`, userID, update.AvatarData, update.AvatarKey, update.ContentType); err != nil { - return err - } - } - return tx.Commit() -} - -// Picture loads a stored profile picture by its unguessable key. -func (s *Store) Picture(ctx context.Context, key string) (Picture, error) { - var picture Picture - err := s.db.QueryRowContext(ctx, ` - SELECT avatar, avatar_content_type, avatar_updated_at - FROM users - WHERE avatar_key = $1 AND avatar IS NOT NULL`, key). - Scan(&picture.Data, &picture.ContentType, &picture.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - return Picture{}, ErrNotFound - } - return picture, err -} diff --git a/internal/store/roles.go b/internal/store/roles.go deleted file mode 100644 index e10bbfa..0000000 --- a/internal/store/roles.go +++ /dev/null @@ -1,505 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/danyel/go-loose/internal/role" -) - -// Role is a named bundle of permissions. A role with no TenantID is built in: -// every tenant sees it and it cannot be edited or deleted. A role with a -// TenantID belongs to that tenant alone. -type Role struct { - ID string `json:"id"` - TenantID *string `json:"tenant_id,omitempty"` - Slug string `json:"slug"` - Name string `json:"name"` - Description string `json:"description"` - System bool `json:"system"` - // Permissions is sorted so that the console can compare two roles without - // worrying about the order they were stored in. - Permissions []string `json:"permissions"` - MemberCount int `json:"member_count"` - // Assignable reports whether the requesting administrator may hand this role - // to somebody. A role is assignable only when every permission it grants is - // one the administrator already holds, so managing members can never be used - // to grant more authority than the administrator has. - Assignable bool `json:"assignable"` -} - -var ( - // ErrRoleInUse is returned when a role that still has members is deleted. - ErrRoleInUse = errors.New("role is still assigned to members") - // ErrRoleSlugTaken is returned when a tenant already uses the requested slug. - ErrRoleSlugTaken = errors.New("role slug is already in use") - // ErrUnknownPermission is returned when a role or catalog entry names a - // permission that is not in the catalog. It is a caller mistake rather than a - // server fault, so callers report it as a bad request. - ErrUnknownPermission = errors.New("unknown permission") -) - -// querier is satisfied by both *sql.DB and *sql.Tx so that the authorization -// helpers can run inside or outside a transaction. -type querier interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row -} - -// scanner is satisfied by both *sql.Row and *sql.Rows. -type scanner interface { - Scan(dest ...any) error -} - -// permissionPredicate renders the one authorization check used by every -// tenant-scoped statement: it holds when the user holds permission in the -// tenant. The arguments are SQL expressions rather than placeholders because the -// queries that need it have already joined memberships under a name of their -// own. Resolving the grant through role_permissions is what lets a tenant define -// its own roles without any Go code having to know they exist. -func permissionPredicate(tenantExpr, userExpr, permissionExpr string) string { - return fmt.Sprintf(`EXISTS ( - SELECT 1 - FROM memberships scoped - JOIN role_permissions granted ON granted.role_id = scoped.role_id - WHERE scoped.tenant_id = %s AND scoped.user_id = %s - AND granted.permission = %s - )`, tenantExpr, userExpr, permissionExpr) -} - -// SystemRoleID returns the id of a built-in role. Callers that seed memberships -// outside a tenant context -- the installer, the bootstrapper -- need it because -// a membership is a role id rather than a name. -func (s *Store) SystemRoleID(ctx context.Context, slug string) (string, error) { - var id string - err := s.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = $1 AND tenant_id IS NULL`, slug).Scan(&id) - if errors.Is(err, sql.ErrNoRows) { - return "", ErrNotFound - } - return id, err -} - -// clientPermissions is the permission aggregate reported to client applications -// through /connect/userinfo. A user with no membership in the owning tenant is -// described by the built-in User role, which is the least privileged one. -const clientPermissions = `COALESCE( - (SELECT json_agg(rp.permission ORDER BY rp.permission) - FROM role_permissions rp WHERE rp.role_id = m.role_id), - (SELECT json_agg(rp.permission ORDER BY rp.permission) - FROM role_permissions rp - JOIN roles fallback ON fallback.id = rp.role_id - WHERE fallback.slug = 'user' AND fallback.tenant_id IS NULL), - '[]'::json)` - -// heldPermissions returns every permission the actor holds in a tenant, sorted. -// It is the basis for both the per-action checks and the escalation guard: an -// actor may never hand out a capability they do not have themselves. -func heldPermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) { - rows, err := q.QueryContext(ctx, ` - SELECT DISTINCT granted.permission - FROM memberships scoped - JOIN role_permissions granted ON granted.role_id = scoped.role_id - WHERE scoped.tenant_id = $1 AND scoped.user_id = $2 - ORDER BY granted.permission`, tenantID, actorID) - if err != nil { - return nil, err - } - defer rows.Close() - held := make([]string, 0) - for rows.Next() { - var permission string - if err := rows.Scan(&permission); err != nil { - return nil, err - } - held = append(held, permission) - } - return held, rows.Err() -} - -// hasPermission reports whether the actor holds one permission in a tenant. -func hasPermission(ctx context.Context, q querier, actorID, tenantID string, permission role.Permission) (bool, error) { - var permitted bool - err := q.QueryRowContext(ctx, - `SELECT `+permissionPredicate("$2", "$1", "$3"), - actorID, tenantID, string(permission)).Scan(&permitted) - return permitted, err -} - -// within reports whether every permission in granted also appears in held. -func within(granted, held []string) bool { - available := make(map[string]bool, len(held)) - for _, permission := range held { - available[permission] = true - } - for _, permission := range granted { - if !available[permission] { - return false - } - } - return true -} - -// normalizePermissions checks that every submitted name is in the catalog and -// returns them deduplicated in a stable order. Validation moved from the Go -// vocabulary to the database when permissions became rows, so a tenant-defined -// permission can be granted like any other while an invented one still cannot be -// stored. -func normalizePermissions(ctx context.Context, q querier, permissions []string) ([]string, error) { - requested := make(map[string]bool, len(permissions)) - for _, value := range permissions { - name := strings.TrimSpace(value) - if name == "" { - continue - } - var known bool - if err := q.QueryRowContext(ctx, - `SELECT EXISTS(SELECT 1 FROM permissions WHERE name = $1)`, name).Scan(&known); err != nil { - return nil, err - } - if !known { - return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, name) - } - requested[name] = true - } - rows, err := q.QueryContext(ctx, ` - SELECT name FROM permissions - WHERE name = ANY($1) - ORDER BY array_position(`+permissionOrder+`, name) NULLS LAST, name`, keys(requested)) - if err != nil { - return nil, err - } - defer rows.Close() - result := make([]string, 0, len(requested)) - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, err - } - result = append(result, name) - } - return result, rows.Err() -} - -// keys returns the members of a set as a slice, for use as a SQL array parameter. -func keys(set map[string]bool) []string { - result := make([]string, 0, len(set)) - for name := range set { - result = append(result, name) - } - return result -} - -// roleGrants reads a role's permissions. -func roleGrants(ctx context.Context, q querier, roleID string) ([]string, error) { - rows, err := q.QueryContext(ctx, ` - SELECT permission FROM role_permissions WHERE role_id = $1 ORDER BY permission`, roleID) - if err != nil { - return nil, err - } - defer rows.Close() - result := make([]string, 0) - for rows.Next() { - var permission string - if err := rows.Scan(&permission); err != nil { - return nil, err - } - result = append(result, permission) - } - return result, rows.Err() -} - -func scanRole(row scanner) (Role, error) { - var item Role - var tenantID sql.NullString - if err := row.Scan(&item.ID, &tenantID, &item.Slug, &item.Name, &item.Description, - &item.System, &item.MemberCount); err != nil { - return Role{}, err - } - if tenantID.Valid { - item.TenantID = &tenantID.String - } - return item, nil -} - -const roleSelect = ` - SELECT r.id, r.tenant_id, r.slug, r.name, r.description, r.is_system, - (SELECT count(*) FROM memberships m WHERE m.role_id = r.id) - FROM roles r ` - -// ListRoles returns the roles available in a tenant: every built-in role plus -// that tenant's own. Reading the catalog needs users.read so the role screen can -// describe a role before anybody is assigned it. -func (s *Store) ListRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) { - permitted, err := hasPermission(ctx, s.db, actorID, tenantID, role.ReadUsers) - if err != nil { - return nil, err - } - if !permitted { - return nil, ErrNotFound - } - roles, err := s.rolesIn(ctx, s.db, actorID, tenantID) - if err != nil { - return nil, err - } - return roles, nil -} - -// AssignableRoles returns only the roles the administrator may hand to a member. -func (s *Store) AssignableRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) { - roles, err := s.ListRoles(ctx, actorID, tenantID) - if err != nil { - return nil, err - } - result := make([]Role, 0, len(roles)) - for _, item := range roles { - if item.Assignable { - result = append(result, item) - } - } - return result, nil -} - -// rolesIn loads the tenant's catalog and marks what the actor may assign. -func (s *Store) rolesIn(ctx context.Context, q querier, actorID, tenantID string) ([]Role, error) { - rows, err := q.QueryContext(ctx, roleSelect+` - WHERE r.tenant_id IS NULL OR r.tenant_id = $1 - ORDER BY r.is_system DESC, - array_position(ARRAY['owner','admin','developer','operator','viewer','user'], r.slug) NULLS LAST, - r.name`, tenantID) - if err != nil { - return nil, err - } - defer rows.Close() - held, err := heldPermissions(ctx, q, actorID, tenantID) - if err != nil { - return nil, err - } - result := make([]Role, 0) - for rows.Next() { - item, err := scanRole(rows) - if err != nil { - return nil, err - } - permissions, err := roleGrants(ctx, q, item.ID) - if err != nil { - return nil, err - } - item.Permissions = permissions - item.Assignable = within(permissions, held) - result = append(result, item) - } - return result, rows.Err() -} - -func roleByID(ctx context.Context, q querier, roleID string) (Role, error) { - item, err := scanRole(q.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1`, roleID)) - if errors.Is(err, sql.ErrNoRows) { - return Role{}, ErrNotFound - } - if err != nil { - return Role{}, err - } - return item, nil -} - -// CreateRole adds a tenant's own role. The caller must hold roles.manage and may -// not mint a role more powerful than one they already hold. -func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, description string, permissions []string) (Role, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Role{}, err - } - defer tx.Rollback() - granted, err := normalizePermissions(ctx, tx, permissions) - if err != nil { - return Role{}, err - } - - permitted, err := hasPermission(ctx, tx, actorID, tenantID, role.ManageRoles) - if err != nil { - return Role{}, err - } - if !permitted { - return Role{}, ErrNotFound - } - grantable, err := grantablePermissions(ctx, tx, actorID, tenantID) - if err != nil { - return Role{}, err - } - if !within(granted, grantable) { - return Role{}, ErrNotFound - } - created, err := insertRole(ctx, tx, tenantID, slug, name, description) - if err != nil { - return Role{}, err - } - for _, permission := range granted { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`, - created.ID, permission); err != nil { - return Role{}, err - } - } - created.Permissions = granted - created.Assignable = true - return created, tx.Commit() -} - -// UpdateRole renames a role and replaces its permissions. Built-in roles are -// immutable and a tenant may only edit a role it owns. -func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, description string, permissions []string) (Role, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Role{}, err - } - defer tx.Rollback() - granted, err := normalizePermissions(ctx, tx, permissions) - if err != nil { - return Role{}, err - } - - existing, err := lockRole(ctx, tx, roleID) - if err != nil { - return Role{}, err - } - if existing.System || existing.TenantID == nil { - return Role{}, ErrNotFound - } - permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles) - if err != nil { - return Role{}, err - } - if !permitted { - return Role{}, ErrNotFound - } - grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID) - if err != nil { - return Role{}, err - } - if !within(granted, grantable) { - return Role{}, ErrNotFound - } - if _, err := tx.ExecContext(ctx, ` - UPDATE roles SET name = $2, description = $3, updated_at = now() WHERE id = $1`, - roleID, name, description); err != nil { - return Role{}, err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = $1`, roleID); err != nil { - return Role{}, err - } - for _, permission := range granted { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`, - roleID, permission); err != nil { - return Role{}, err - } - } - existing.Name, existing.Description, existing.Permissions = name, description, granted - return existing, tx.Commit() -} - -// DeleteRole removes a tenant's own role. Built-in roles and roles that still -// have members cannot be deleted, so nobody is left pointing at nothing. -func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - existing, err := lockRole(ctx, tx, roleID) - if err != nil { - return err - } - if existing.System || existing.TenantID == nil { - return ErrNotFound - } - permissions, err := roleGrants(ctx, tx, roleID) - if err != nil { - return err - } - permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles) - if err != nil { - return err - } - if !permitted { - return ErrNotFound - } - grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID) - if err != nil { - return err - } - if !within(permissions, grantable) { - return ErrNotFound - } - if existing.MemberCount > 0 { - return ErrRoleInUse - } - if _, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID); err != nil { - return err - } - return tx.Commit() -} - -// resolveRoleID validates that a role may be assigned to a member of the tenant -// and returns its id. A role belonging to another tenant is reported as not -// found so that role ids cannot be probed across tenants. -func resolveRoleID(ctx context.Context, tx *sql.Tx, tenantID, roleID string, held []string) (string, error) { - item, err := roleByID(ctx, tx, roleID) - if err != nil { - return "", err - } - if item.TenantID != nil && *item.TenantID != tenantID { - return "", ErrNotFound - } - granted, err := roleGrants(ctx, tx, roleID) - if err != nil { - return "", err - } - if !within(granted, held) { - return "", ErrNotFound - } - return roleID, nil -} - -// lockRole loads a role and holds the row for the rest of the transaction. -func lockRole(ctx context.Context, tx *sql.Tx, roleID string) (Role, error) { - item, err := scanRole(tx.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1 FOR UPDATE`, roleID)) - if errors.Is(err, sql.ErrNoRows) { - return Role{}, ErrNotFound - } - if err != nil { - return Role{}, err - } - permissions, err := roleGrants(ctx, tx, roleID) - if err != nil { - return Role{}, err - } - item.Permissions = permissions - return item, nil -} - -// insertRole writes the role row, translating a slug collision into a stable -// error rather than a driver-specific constraint violation. -func insertRole(ctx context.Context, tx *sql.Tx, tenantID, slug, name, description string) (Role, error) { - item, err := scanRole(tx.QueryRowContext(ctx, ` - INSERT INTO roles(tenant_id, slug, name, description) - VALUES ($1, $2, $3, $4) - RETURNING id, tenant_id, slug, name, description, is_system, 0`, - tenantID, slug, name, description)) - if err != nil { - if isUniqueViolation(err) { - return Role{}, ErrRoleSlugTaken - } - return Role{}, err - } - return item, nil -} - -func isUniqueViolation(err error) bool { - return err != nil && strings.Contains(err.Error(), "duplicate key value") -} diff --git a/internal/store/roles_integration_test.go b/internal/store/roles_integration_test.go deleted file mode 100644 index c4fc7ad..0000000 --- a/internal/store/roles_integration_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "testing" - - "github.com/danyel/go-loose/internal/role" - "github.com/danyel/go-loose/internal/store" -) - -// rolesFixture creates a tenant owned by owner plus a developer member. The -// developer is the interesting actor: it administers members but does not hold -// roles.manage, so it is the boundary the escalation guards are tested against. -func rolesFixture(t *testing.T) (s *store.Store, ctx context.Context, owner, developer, tenantID string) { - t.Helper() - s, _, ctx = integrationStore(t) - owner = newUser(t, s, ctx, "owner@example.test") - tenantID = newTenant(t, s, ctx, owner, "nmbs").ID - developer = addMember(t, s, ctx, owner, tenantID, "developer@example.test", "developer") - return s, ctx, owner, developer, tenantID -} - -func systemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string { - t.Helper() - id, err := s.SystemRoleID(ctx, slug) - if err != nil { - t.Fatalf("look up built-in role %q: %v", slug, err) - } - return id -} - -func TestTenantCanDefineItsOwnRoleAndItIsEnforced(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - application, err := s.CreateApplication(ctx, owner, store.Application{ - TenantID: tenantID, Slug: "guess", Name: "Guess", AllowedHosts: []string{}, - }) - if err != nil { - t.Fatalf("create application: %v", err) - } - - created, err := s.CreateRole(ctx, owner, tenantID, "release-manager", - "Release manager", "Prepares releases.", - []string{string(role.ReadConsole), string(role.ManageContracts), string(role.ReadUsers)}) - if err != nil { - t.Fatalf("create role: %v", err) - } - if created.System { - t.Error("a tenant role must not be marked as built in") - } - if created.TenantID == nil || *created.TenantID != tenantID { - t.Errorf("tenant id = %v, want %s", created.TenantID, tenantID) - } - if !contains(created.Permissions, string(role.ManageContracts)) { - t.Errorf("permissions = %v, want contracts.manage", created.Permissions) - } - - // The role grants exactly what it says and nothing more. - member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer") - if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil { - t.Fatalf("assign the custom role: %v", err) - } - _, err = s.CreateAPIKey(ctx, member, application.ID, "key", "gl_prefix", secretHash("custom-denied"), nil) - assertPermission(t, "custom role issuing a key", err, false) - _, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil) - assertPermission(t, "custom role importing a contract", err, true) -} - -func TestCustomRoleIsScopedToItsOwnTenant(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - other := newUser(t, s, ctx, "other-owner@example.test") - otherTenant := newTenant(t, s, ctx, other, "ypto").ID - - created, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", []string{string(role.ReadConsole)}) - if err != nil { - t.Fatalf("create role: %v", err) - } - - roles, err := s.ListRoles(ctx, other, otherTenant) - if err != nil { - t.Fatalf("list roles: %v", err) - } - for _, item := range roles { - if item.ID == created.ID { - t.Fatal("a role leaked across tenants") - } - } - if err := s.DeleteRole(ctx, other, created.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("cross-tenant delete = %v, want ErrNotFound", err) - } - if _, err := s.UpdateRole(ctx, other, created.ID, "Hijacked", "", nil); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("cross-tenant update = %v, want ErrNotFound", err) - } -} - -func TestBuiltInRolesCannotBeEditedOrDeleted(t *testing.T) { - s, ctx, owner, _, _ := rolesFixture(t) - ownerRole := systemRole(t, s, ctx, "owner") - if _, err := s.UpdateRole(ctx, owner, ownerRole, "Supreme", "", nil); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("editing a built-in role = %v, want ErrNotFound", err) - } - if err := s.DeleteRole(ctx, owner, ownerRole); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("deleting a built-in role = %v, want ErrNotFound", err) - } -} - -func TestRoleInUseCannotBeDeleted(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - created, err := s.CreateRole(ctx, owner, tenantID, "support", "Support", "", []string{string(role.ReadConsole)}) - if err != nil { - t.Fatalf("create role: %v", err) - } - member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer") - if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil { - t.Fatalf("assign role: %v", err) - } - if err := s.DeleteRole(ctx, owner, created.ID); !errors.Is(err, store.ErrRoleInUse) { - t.Fatalf("delete an assigned role = %v, want ErrRoleInUse", err) - } - // Once nobody holds it the delete goes through. - if err := s.SetUserAccess(ctx, owner, tenantID, member, systemRole(t, s, ctx, "viewer"), nil); err != nil { - t.Fatalf("move the member off the role: %v", err) - } - if err := s.DeleteRole(ctx, owner, created.ID); err != nil { - t.Fatalf("delete an unused role: %v", err) - } -} - -// TestRoleAssignmentCannotEscalate is the guard that matters most now that -// developer and operator may manage members: they must not be able to hand -// somebody a role more powerful than their own, which would otherwise be a -// straightforward route to owner. -func TestRoleAssignmentCannotEscalate(t *testing.T) { - s, ctx, owner, developer, tenantID := rolesFixture(t) - victim := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer") - ownerRole := systemRole(t, s, ctx, "owner") - - if err := s.SetUserAccess(ctx, developer, tenantID, victim, ownerRole, nil); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer promoting a member to owner = %v, want ErrNotFound", err) - } - if _, err := s.InviteUser(ctx, developer, tenantID, "sneaky@example.test", "Sneaky", ownerRole, "hash"); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer inviting as owner = %v, want ErrNotFound", err) - } - if _, err := s.CreateRole(ctx, developer, tenantID, "shadow-owner", "Shadow", "", - []string{string(role.ReadConsole), string(role.ManageTenants)}); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer creating an owner-grade role = %v, want ErrNotFound", err) - } - // Nor may they define any role at all: roles.manage is not theirs to hold, - // however weak the role they were trying to mint. - if _, err := s.CreateRole(ctx, developer, tenantID, "teammate", "Teammate", "", - []string{string(role.ReadConsole), string(role.ReadUsers)}); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer creating any role = %v, want ErrNotFound", err) - } - // The developer can still maintain members using the roles already offered. - if _, err := s.InviteUser(ctx, developer, tenantID, "teammate@example.test", "Teammate", - systemRole(t, s, ctx, "viewer"), "hash"); err != nil { - t.Fatalf("developer inviting a member: %v", err) - } -} - -func TestRolesManageIsRequiredToDefineRoles(t *testing.T) { - s, ctx, _, developer, tenantID := rolesFixture(t) - if _, err := s.ListRoles(ctx, developer, tenantID); err != nil { - t.Fatalf("a developer may read the role catalog: %v", err) - } - if _, err := s.CreateRole(ctx, developer, tenantID, "nope", "Nope", "", - []string{string(role.ReadConsole)}); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("developer creating a role = %v, want ErrNotFound", err) - } -} - -func TestAssignableRolesExcludeAnythingAboveTheActor(t *testing.T) { - s, ctx, _, developer, tenantID := rolesFixture(t) - assignable, err := s.AssignableRoles(ctx, developer, tenantID) - if err != nil { - t.Fatalf("assignable roles: %v", err) - } - ownerRole := systemRole(t, s, ctx, "owner") - found := 0 - for _, item := range assignable { - if !item.Assignable { - t.Errorf("role %q was returned but is not marked assignable", item.Slug) - } - if contains(item.Permissions, string(role.ManageTenants)) { - t.Errorf("developer may assign %q, which grants tenants.manage", item.Slug) - } - if item.ID == ownerRole { - t.Error("a developer must not be offered the owner role") - } - found++ - } - if found == 0 { - t.Fatal("a developer should still be offered the roles within their authority") - } -} - -func TestUnknownPermissionIsRejectedWhenDefiningARole(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - _, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "", - []string{string(role.ReadConsole), "superpower"}) - if err == nil { - t.Fatal("a role must not be creatable with an unknown permission") - } -} - -func TestDuplicateRoleSlugIsRejected(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - if _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", - []string{string(role.ReadConsole)}); err != nil { - t.Fatalf("create role: %v", err) - } - _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Another", "", - []string{string(role.ReadConsole)}) - if !errors.Is(err, store.ErrRoleSlugTaken) { - t.Fatalf("duplicate slug = %v, want ErrRoleSlugTaken", err) - } -} - -// TestTenantKeepsAnOwner guards the demotion path: the last owner of a tenant -// must not be able to walk away from it and lock every administrator out. -func TestTenantKeepsAnOwner(t *testing.T) { - s, ctx, owner, _, tenantID := rolesFixture(t) - admin := addMember(t, s, ctx, owner, tenantID, "admin@example.test", "admin") - adminRole := systemRole(t, s, ctx, "admin") - ownerRole := systemRole(t, s, ctx, "owner") - - // The owner cannot simply step down while nobody else holds the role. - if err := s.SetUserAccess(ctx, owner, tenantID, owner, adminRole, nil); !errors.Is(err, store.ErrLastOwner) { - t.Fatalf("owner stepping down alone = %v, want ErrLastOwner", err) - } - // Handing the role over first makes the step down legal. - if err := s.SetUserAccess(ctx, owner, tenantID, admin, ownerRole, nil); err != nil { - t.Fatalf("promote the admin to owner: %v", err) - } - if err := s.SetUserAccess(ctx, admin, tenantID, owner, adminRole, nil); err != nil { - t.Fatalf("step down once a second owner exists: %v", err) - } - // The admin is now the only owner and must not be able to walk away from it. - if err := s.SetUserAccess(ctx, admin, tenantID, admin, adminRole, nil); !errors.Is(err, store.ErrLastOwner) { - t.Fatalf("demoting the last owner = %v, want ErrLastOwner", err) - } -} diff --git a/internal/store/store.go b/internal/store/store.go index dfd194a..b839ee5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -10,14 +10,10 @@ import ( "time" "github.com/danyel/go-loose/internal/contract" - "github.com/danyel/go-loose/internal/role" ) var ErrNotFound = errors.New("not found") -// ErrTenantSlugTaken is returned when a tenant already uses the requested slug. -var ErrTenantSlugTaken = errors.New("tenant slug is already in use") - type Store struct { db *sql.DB } @@ -33,9 +29,6 @@ type Tenant struct { Slug string `json:"slug"` Name string `json:"name"` Role string `json:"role"` - // Permissions is read from the role's own rows rather than from a Go matrix, - // so a tenant that defines its own roles is described accurately. - Permissions []string `json:"permissions"` } type Application struct { @@ -93,15 +86,8 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri return User{}, err } defer tx.Rollback() - // The identity provider owns the display name until the user customizes it - // in their profile. The row must still match on every login, otherwise the - // insert below would collide with the unique email index. err = tx.QueryRowContext(ctx, ` - UPDATE users - SET subject = $1, - email = lower($2), - display_name = CASE WHEN name_customized THEN display_name ELSE $3 END, - last_login_at = now() + UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now() WHERE lower(email) = lower($2) RETURNING id, email, display_name`, subject, email, displayName). Scan(&user.ID, &user.Email, &user.DisplayName) @@ -132,22 +118,12 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin if err != nil { return err } - // Ownership is granted only while the tenant still has none. The condition is - // about owners rather than about members: the installer's demonstration seed - // creates tenants that already hold administrators, and refusing ownership - // there would leave a local administrator bootstrapped afterwards with an - // account that can never reach the console. A tenant that already has an owner - // is still never taken over by a later bootstrap. if _, err = tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role_id) - SELECT $1, $2, r.id - FROM roles r - WHERE r.slug = $3 AND r.tenant_id IS NULL - AND NOT EXISTS ( - SELECT 1 FROM memberships m - JOIN roles held ON held.id = m.role_id - WHERE m.tenant_id = $1 AND held.slug = 'owner') - ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil { + INSERT INTO memberships(tenant_id, user_id, role) + SELECT $1, $2, 'owner' + WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1) + OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2) + ON CONFLICT DO NOTHING`, tenantID, userID); err != nil { return err } if _, err = tx.ExecContext(ctx, ` @@ -170,15 +146,9 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT t.id, t.slug, t.name, r.slug, - COALESCE(( - SELECT json_agg(rp.permission ORDER BY rp.permission) - FROM role_permissions rp - WHERE rp.role_id = m.role_id - ), '[]'::json) + SELECT t.id, t.slug, t.name, m.role FROM tenants t JOIN memberships m ON m.tenant_id = t.id - JOIN roles r ON r.id = m.role_id WHERE m.user_id = $1 ORDER BY t.name`, userID) if err != nil { @@ -188,11 +158,7 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error result := make([]Tenant, 0) for rows.Next() { var item Tenant - var permissions []byte - if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role, &permissions); err != nil { - return nil, err - } - if err := json.Unmarshal(permissions, &item.Permissions); err != nil { + if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil { return nil, err } result = append(result, item) @@ -209,34 +175,13 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te var tenant Tenant err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name). Scan(&tenant.ID, &tenant.Slug, &tenant.Name) - if isUniqueViolation(err) { - // Reporting this as a generic failure would surface a 500 for what is an - // ordinary mistake, and the console has no way to explain it. - return Tenant{}, ErrTenantSlugTaken - } if err != nil { return Tenant{}, err } - // Whoever creates a tenant owns it. - var permissions []byte - err = tx.QueryRowContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role_id) - SELECT $1, $2, r.id FROM roles r - WHERE r.slug = 'owner' AND r.tenant_id IS NULL - RETURNING (SELECT slug FROM roles WHERE slug = 'owner' AND tenant_id IS NULL), - COALESCE(( - SELECT json_agg(rp.permission ORDER BY rp.permission) - FROM role_permissions rp - JOIN roles granted ON granted.id = rp.role_id - WHERE granted.slug = 'owner' AND granted.tenant_id IS NULL - ), '[]'::json)`, - tenant.ID, userID).Scan(&tenant.Role, &permissions) - if err != nil { - return Tenant{}, err - } - if err := json.Unmarshal(permissions, &tenant.Permissions); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil { return Tenant{}, err } + tenant.Role = "owner" if err := tx.Commit(); err != nil { return Tenant{}, err } @@ -279,11 +224,12 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic row := s.db.QueryRowContext(ctx, ` INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts) SELECT $1, $2, $3, $4, $5 - WHERE `+permissionPredicate("$1", "$6", "$7")+` + WHERE EXISTS ( + SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin') + ) RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts), client_id, to_json(redirect_uris), false`, - app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID, - string(role.ManageApplications)) + app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID) var allowedHosts, redirectURIs []byte err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady) if errors.Is(err, sql.ErrNoRows) { @@ -326,9 +272,10 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by) SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6 FROM applications a - WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$6", "$7")+` + JOIN memberships m ON m.tenant_id = a.tenant_id + WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin') RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`, - applicationID, name, prefix, hash, expiresAt, userID, string(role.ManageKeys)). + applicationID, name, prefix, hash, expiresAt, userID). Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return APIKey{}, ErrNotFound @@ -339,8 +286,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error { result, err := s.db.ExecContext(ctx, ` UPDATE api_keys k SET status = 'revoked', revoked_at = now() - WHERE k.id = $1 AND `+permissionPredicate("k.tenant_id", "$2", "$3"), - keyID, userID, string(role.ManageKeys)) + FROM memberships m + WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`, + keyID, userID) if err != nil { return err } @@ -400,10 +348,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string, INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by) SELECT a.id, $2, $3, $4, $5 FROM applications a - WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$5", "$6")+` + JOIN memberships m ON m.tenant_id = a.tenant_id + WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin') RETURNING id, application_id, version, source_url, created_at`, - applicationID, document.Version, sourceURL, document.JSON, userID, - string(role.ManageContracts)). + applicationID, document.Version, sourceURL, document.JSON, userID). Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return Contract{}, ErrNotFound diff --git a/web/account.js b/web/account.js deleted file mode 100644 index 9e65a94..0000000 --- a/web/account.js +++ /dev/null @@ -1,132 +0,0 @@ -// The header account control. -// -// One component, mounted into every page by the placeholder below it, so the -// identity and the two things a person can do about their own account live -// together. It replaced a separate sign-out button, which meant the header showed -// two controls describing one fact. -// -// The menu is rendered here rather than duplicated into each page's markup so that -// the keyboard handling, the outside-click behaviour and the sign-out form exist -// once. The trade is one small request for the profile, which the pages that show a -// tenant context were making anyway. -// -// The sign-out item is a real form posting to /auth/logout rather than a fetch, so -// signing out works without JavaScript and cannot be triggered by a stray -// cross-site request: the endpoint only accepts POST. -// -// Everything is wrapped in an IIFE and exposed only as window.mountAccountMenu. -// These are classic scripts sharing one global scope, so a top-level const here -// would collide with the escapeHTML and initials that each page already declares, -// and the collision is a SyntaxError that stops the whole script before it runs. - -(function () { - const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }[character])); - - const initials = name => String(name || '?') - .split(/[\s@._-]+/) - .filter(Boolean) - .slice(0, 2) - .map(part => part[0].toUpperCase()) - .join('') || '?'; - - async function loadProfile() { - const response = await fetch('/api/v1/profile', { - credentials: 'same-origin', - headers: {accept: 'application/json'} - }); - if (response.status === 401) { - // Signed out: the page itself will redirect, so there is nothing to show. - return null - } - if (!response.ok) return null; - return response.json() - } - - // Mounts the control into a placeholder element. Exported on window because the - // pages load their own script and there is no module loader here; the alternative - // would be repeating this markup and this wiring in every page. - function mount(host) { - if (!host || host.dataset.mounted === 'true') return; - host.dataset.mounted = 'true'; - host.classList.add('account'); - host.innerHTML = ` - -
`; - - const trigger = host.querySelector('.account-trigger'); - const menu = host.querySelector('.account-menu'); - const note = host.querySelector('.account-note'); - - const setOpen = open => { - menu.hidden = !open; - trigger.setAttribute('aria-expanded', String(open)); - }; - - trigger.addEventListener('click', event => { - event.stopPropagation(); - setOpen(menu.hidden) - }); - - // Escape closes and returns focus, which is the one keyboard path a menu has to - // get right: without it the menu is a trap for anyone not using a mouse. - document.addEventListener('keydown', event => { - if (event.key !== 'Escape' || menu.hidden) return; - setOpen(false); - trigger.focus() - }); - - // A click anywhere else dismisses it. Capture phase, so a click that also - // dismisses a dialog underneath does not race this. - document.addEventListener('click', () => setOpen(false), true); - - loadProfile().then(profile => { - if (!profile) { - // No session: leave the control hidden rather than showing an empty name. - return - } - const name = profile.display_name || profile.email || ''; - // The trigger and the menu both carry the name. Filling only the menu - // leaves the header showing a picture and a caret with no name beside - // them, which is the one thing the control exists to say. - host.querySelector('.account-name').textContent = name; - host.querySelector('.account-display-name').textContent = name; - host.querySelector('.account-email').textContent = profile.email || ''; - const slot = host.querySelector('.account-avatar-slot'); - slot.innerHTML = profile.avatar_url - ? `` - : `${escapeHTML(initials(name))}`; - trigger.hidden = false; - - // Only explain the cross-application effect when it is true. A person with - // no hosted-login sessions has nothing to sign out of elsewhere. - const usesHostedLogin = (profile.memberships || []).length > 0; - note.hidden = !usesHostedLogin - }) - }; - - window.mountAccountMenu = mount - - document.addEventListener('DOMContentLoaded', () => { - document.querySelectorAll('[data-account-menu]').forEach(mount) - }) -})() \ No newline at end of file diff --git a/web/app.js b/web/app.js index 41619c2..191cd90 100644 --- a/web/app.js +++ b/web/app.js @@ -1,12 +1,5 @@ -const state = { - tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], - roles: [], permissions: [], profile: null, selectedTenant: '' -}; -// Capability names mirror internal/role. The server sends the grants for the -// selected tenant so the console never re-implements the matrix. -const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission)); +const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''}; const $ = selector => document.querySelector(selector); - const $$ = selector => [...document.querySelectorAll(selector)]; const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', @@ -45,46 +38,14 @@ async function load() { const data = await api('/api/v1/dashboard'); Object.assign(state, data); if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || ''; - await loadRoles(); + $('#identity').textContent = data.user.display_name || data.user.email; render(); } -// loadRoles fetches the role catalog for the selected tenant. Roles are rows now -// rather than a fixed list, so they have to be asked for per tenant, and the -// server marks which ones the signed-in user is allowed to hand out. -async function loadRoles() { - if (!state.selectedTenant) { - state.roles = []; - return - } - try { - state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles - } catch (error) { - // A user who cannot read the role catalog still gets the rest of the - // console; the invite and access dialogs simply offer nothing. - state.roles = []; - } -} - - -// assignableRoles is what the invite and access dialogs may offer: the server -// refuses to hand out a role more powerful than the caller's own, so offering -// anything else would only produce an error. -function assignableRoles() { - return state.roles.filter(item => item.assignable) -} - -function roleLabel(roleID) { - return state.roles.find(item => item.id === roleID)?.name || roleID -} - function render() { const tenantID = state.selectedTenant; const selectedTenant = state.tenants.find(item => item.id === tenantID); - const canManageApplications = grants(selectedTenant, 'applications.manage'); - const canManageKeys = grants(selectedTenant, 'keys.manage'); - const canManageContracts = grants(selectedTenant, 'contracts.manage'); - const canManageUsers = grants(selectedTenant, 'users.manage'); + const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role); const tenantApps = state.applications.filter(item => item.tenant_id === tenantID); const tenantAppIDs = new Set(tenantApps.map(item => item.id)); const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id)); @@ -98,17 +59,13 @@ function render() { $('#user-count').textContent = tenantUsers.length + state.pending_users.length; $('#tenant-context').innerHTML = state.tenants.map(item => ``).join(''); $('#new-tenant').hidden = !state.is_system_administrator; - $('#tenant-list').innerHTML = state.tenants.map(item => `
${escapeHTML(item.role)}

${escapeHTML(item.name)}

${metaRow('Slug', item.slug)}${metaRow('Identifier', item.id.slice(0, 8), item.id)}
`).join('') || empty('No tenants yet'); - $('#app-list').innerHTML = tenantApps.map(item => `
${escapeHTML(item.tenant_slug)}

${escapeHTML(item.name)}

${escapeHTML(item.description || 'No description')}

${metaRow('Client login', item.client_ready ? 'Ready' : 'Not configured')}${item.client_id ? metaRow('Client ID', item.client_id, item.client_id) : ''}
${canManageApplications ? `` : ''}
`).join('') || empty('No applications yet'); - $('#key-list').innerHTML = tenantKeys.map(item => `${escapeHTML(item.name)}${escapeHTML(apps.get(item.application_id)?.name || '—')}${escapeHTML(item.prefix)}…${escapeHTML(item.status)}${date(item.last_used_at)}
${canManageKeys && item.status === 'active' ? `` : ''}
`).join('') || rowEmpty(6, 'No keys issued'); + $('#tenant-list').innerHTML = state.tenants.map(item => `
${escapeHTML(item.role)}

${escapeHTML(item.name)}

${escapeHTML(item.slug)}

Tenant${escapeHTML(item.id.slice(0, 8))}
`).join('') || empty('No tenants yet'); + $('#app-list').innerHTML = tenantApps.map(item => `
${escapeHTML(item.tenant_slug)}

${escapeHTML(item.name)}

${escapeHTML(item.description || 'No description')}

${item.client_ready ? 'Client login ready' : 'Client login not configured'}${escapeHTML(item.client_id)}
${canManageTenant ? `` : ''}
`).join('') || empty('No applications yet'); + $('#key-list').innerHTML = tenantKeys.map(item => `${escapeHTML(item.name)}${escapeHTML(apps.get(item.application_id)?.name || '—')}${escapeHTML(item.prefix)}…${escapeHTML(item.status)}${date(item.last_used_at)}${canManageTenant && item.status === 'active' ? `` : ''}`).join('') || rowEmpty(6, 'No keys issued'); $('#contract-list').innerHTML = tenantContracts.map(item => `${escapeHTML(apps.get(item.application_id)?.name || '—')}${escapeHTML(item.version)}${item.endpoint_count}${item.source_url ? `source` : 'Pasted'}${date(item.created_at)}`).join('') || rowEmpty(5, 'No contracts imported'); - const pending = state.is_system_administrator ? state.pending_users.map(item => `${escapeHTML(item.display_name)}
${escapeHTML(item.email)}Waiting room—pending0
`).join('') : ''; - $('#user-list').innerHTML = tenantUsers.map(item => `${avatarCell(item)}${escapeHTML(item.display_name)}
${escapeHTML(item.email)}${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}${escapeHTML(roleLabel(item.role_id) || item.role)}${escapeHTML(item.status)}${item.application_ids.length}
${canManageUsers ? `` : ''}
`).join('') + pending || rowEmpty(6, 'No users'); - renderRoleOptions(); - $$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications); - $$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys); - $$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts); - $$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers); + const pending = state.is_system_administrator ? state.pending_users.map(item => `${escapeHTML(item.display_name)}
${escapeHTML(item.email)}Waiting room—pending0`).join('') : ''; + $('#user-list').innerHTML = tenantUsers.map(item => `${escapeHTML(item.display_name)}
${escapeHTML(item.email)}${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}${escapeHTML(item.role)}${escapeHTML(item.status)}${item.application_ids.length}${canManageTenant ? `` : ''}`).join('') + pending || rowEmpty(6, 'No users'); + $$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant); $$('.tenant-select').forEach(select => { select.innerHTML = state.tenants.map(item => ``).join(''); select.value = tenantID; @@ -116,31 +73,7 @@ function render() { $$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => ``).join('')); } -// renderRoleOptions fills both role pickers. The current value is preserved so -// that re-rendering the table does not silently reset a pending edit. -function renderRoleOptions() { - const options = assignableRoles(); - const html = options.map(item => ``).join(''); - for (const id of ['#invite-role', '#access-role']) { - const select = $(id); - if (!select) continue; - const current = select.value; - select.innerHTML = html; - if (current && options.some(item => item.id === current)) select.value = current - } -} - const empty = text => `

${text}

`; -// One labelled pair in a card's footer. The value is the half that can be long -- -// a client identifier runs to forty-odd characters -- so it is the half allowed to -// shrink and be truncated, with the whole value kept in the title for anyone who -// needs to copy it. -const metaRow = (label, value, title = value) => - `
${escapeHTML(label)}${escapeHTML(value)}
`; -const initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?'; -const avatarCell = item => item.avatar_url - ? `` - : `${escapeHTML(initials(item.display_name || item.email))}`; const rowEmpty = (span, text) => `${text}`; function formJSON(form) { @@ -256,10 +189,7 @@ document.addEventListener('click', async event => { form.tenant_id.value = user.tenant_id; form.approval_mode.value = 'false'; $('#approval-tenant-label').hidden = true; - // A role the caller may not hand out is not in the picker, so fall back - // to the first one they can assign rather than submitting the old value. - const current = assignableRoles().some(item => item.id === user.role_id); - form.role_id.value = current ? user.role_id : assignableRoles()[0]?.id || ''; + form.role.value = user.role; renderAccessApps(user.tenant_id, user.application_ids); $('#access-dialog').showModal(); return @@ -271,7 +201,7 @@ document.addEventListener('click', async event => { form.approval_mode.value = 'true'; $('#approval-tenant-label').hidden = false; form.approval_tenant_id.value = state.selectedTenant; - form.role_id.value = assignableRoles()[0]?.id || ''; + form.role.value = 'viewer'; renderAccessApps(state.selectedTenant, []); $('#access-dialog').showModal() } @@ -285,19 +215,21 @@ function showSecret(title, description, secret) { $('#secret-dialog').showModal() } -// The palette and appearance controls are the components shipped by the theme -// service, which own data-theme and data-mode on . This file used to drive a -// - - - -
+ + + +
@@ -66,15 +58,15 @@
-

SERVICE CATALOG

Applications

+

SERVICE CATALOG

Applications

-

CREDENTIALS

API keys

+

CREDENTIALS

API keys

NameApplicationPrefixStatusLast used
-

DISCOVERY

OpenAPI contracts

+

DISCOVERY

OpenAPI contracts

ApplicationOpenAPIEndpointsSourceImported
@@ -82,9 +74,8 @@
-

CLIENT IDENTITIES

Users & access

+

CLIENT IDENTITIES

Users & access

UserTenantRoleStatusApplications
-

Roles are per tenant and grant capabilities. Manage roles to review or define your own. Open your own profile to change your name and picture.

@@ -95,16 +86,10 @@

Issue API key

Import OpenAPI contract

Configure client login

Saving rotates the client secret. Existing deployments must be updated immediately.

-

Invite tenant user

You can only assign roles that are no more powerful than your own.

-

Manage user access

Applications
+

Invite tenant user

+

Manage user access

Applications

Secret issued

This secret is shown once. Copy it now.

- - - diff --git a/web/install.html b/web/install.html index af29746..45a29e1 100644 --- a/web/install.html +++ b/web/install.html @@ -8,17 +8,16 @@

FIRST-RUN INSTALLER

Initialize
Go Loose.

-

Configure OIDC identity provider and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.

+

Configure Google identity and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.

- - - - + + +
-

In your identity provider console, add the redirect URL above under Authorized redirect URIs. After installation you will continue to the provider, then return to Go Loose with a secure signed session.

+

In Google Cloud Console, add the callback URL above under Authorized redirect URIs. After installation you will continue to Google, then return to Go Loose with a secure signed session.

diff --git a/web/login.html b/web/login.html index 5f19d02..f98f06b 100644 --- a/web/login.html +++ b/web/login.html @@ -4,7 +4,7 @@
-
{{LOGIN_PROMPT}}
+
goloose@access-plane:~
 ██████╗  ██████╗     ██╗      ██████╗  ██████╗ ███████╗███████╗
 ██╔════╝ ██╔═══██╗    ██║     ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
@@ -12,9 +12,9 @@
 ██║   ██║██║   ██║    ██║     ██║   ██║██║   ██║╚════██║██╔══╝
 ╚██████╔╝╚██████╔╝    ███████╗╚██████╔╝╚██████╔╝███████║███████╗
  ╚═════╝  ╚═════╝     ╚══════╝ ╚═════╝  ╚═════╝ ╚══════╝╚══════╝
-

{{LOGIN_CONTEXT}}

-

{{LOGIN_PROMPT}}$ initialize secure session

-

{{LOGIN_MESSAGE}}

+

{{LOGIN_CONTEXT}}

+

goloose@access-plane:~$ initialize secure session

+

{{LOGIN_MESSAGE}}

-

{{LOGIN_PROMPT}}$ █

+

goloose@access-plane:~$ █

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

YOUR ACCOUNT

-

Profile.

-

Your name, picture, and the tenant roles you hold. Applications such as go-tell read the picture link and your role through the login endpoints.

-
-
- - - -
-
-

Profile picture

-
- - – -
-
- -
- - -
-
- -
- -
-

Account

-
- - -
-
System administrator
—
-
Member since
—
-
Last login
—
-
Name managed by
—
-
- -
-
- -
-

Tenant roles

-
TenantSlugRoleCapabilities
-
-
-
- - - - - - \ No newline at end of file diff --git a/web/profile.js b/web/profile.js deleted file mode 100644 index 60a253c..0000000 --- a/web/profile.js +++ /dev/null @@ -1,155 +0,0 @@ -const $ = selector => document.querySelector(selector); -const $$ = selector => [...document.querySelectorAll(selector)]; -const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' -}[c])); -const date = value => value ? new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(value)) : 'Never'; -const notice = (message, error = false) => { - const el = $('#notice'); - el.textContent = message; - el.hidden = false; - el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)'; - clearTimeout(notice.timer); - notice.timer = setTimeout(() => el.hidden = true, 5000) -}; -const avatar = {dataURL: '', fileName: '', pending: false}; - -async function api(path, options = {}) { - const response = await fetch(path, { - credentials: 'same-origin', ...options, - headers: {'Content-Type': 'application/json', ...(options.headers || {})} - }); - if (response.status === 401) { - location.href = '/login'; - throw new Error('Session expired') - } - const body = response.status === 204 ? null : await response.json(); - if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); - return body; -} - -async function load() { - render(await api('/api/v1/profile')) -} - -function render(profile) { - $('#display-name').value = profile.display_name; - $('#email').value = profile.email; - $('#system-admin').textContent = profile.is_system_administrator ? 'Yes' : 'No'; - $('#created-at').textContent = date(profile.created_at); - $('#last-login').textContent = date(profile.last_login_at); - $('#name-source').textContent = profile.name_customized ? 'You' : 'Your identity provider'; - renderAvatar(profile); - $('#membership-list').innerHTML = profile.memberships.map(item => ` - ${escapeHTML(item.tenant_name)} - ${escapeHTML(item.tenant_slug)} - ${escapeHTML(item.role)} - ${item.permissions.map(permission => `${escapeHTML(permission)}`).join('') || 'None'} - `).join('') || 'No tenant role assigned yet' -} - -function renderAvatar(profile) { - const image = $('#avatar-image'), fallback = $('#avatar-fallback'), link = $('#avatar-url'); - const source = avatar.pending ? avatar.dataURL : profile.avatar_url; - $('#avatar-remove').hidden = !profile.avatar_url; - $('#copy-avatar').hidden = !profile.avatar_url; - if (source) { - image.src = source; - image.hidden = false; - fallback.hidden = true - } else { - image.hidden = true; - image.removeAttribute('src'); - fallback.hidden = false; - fallback.textContent = initials(profile.display_name || profile.email) - } - link.textContent = profile.avatar_url || (avatar.pending ? 'Saved on upload' : 'No picture uploaded') -} - -function initials(name) { - return String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?' -} - -$('#profile-form').addEventListener('submit', async event => { - event.preventDefault(); - try { - render(await api('/api/v1/profile', { - method: 'PUT', - body: JSON.stringify({display_name: $('#display-name').value}) - })); - notice('Profile saved') - } catch (error) { - notice(error.message, true) - } -}); - -$('#avatar-file').addEventListener('change', async event => { - const file = event.target.files?.[0]; - avatar.pending = false; - if (!file) { - $('#avatar-save').disabled = true; - return - } - if (file.size > 2 * 1024 * 1024) { - notice('The picture must be 2 MiB or smaller', true); - event.target.value = ''; - $('#avatar-save').disabled = true; - return - } - avatar.dataURL = await readAsDataURL(file); - avatar.fileName = file.name; - avatar.pending = true; - $('#avatar-save').disabled = false; - renderAvatar({avatar_url: '', display_name: $('#display-name').value, email: $('#email').value}) -}); - -$('#avatar-form').addEventListener('submit', async event => { - event.preventDefault(); - if (!avatar.pending) return; - try { - const profile = await api('/api/v1/profile', { - method: 'PUT', body: JSON.stringify({avatar: avatar.dataURL}) - }); - avatar.pending = false; - $('#avatar-file').value = ''; - $('#avatar-save').disabled = true; - render(profile); - notice('Profile picture updated') - } catch (error) { - notice(error.message, true) - } -}); - -$('#avatar-remove').addEventListener('click', async () => { - if (!confirm('Remove your profile picture?')) return; - try { - const profile = await api('/api/v1/profile', {method: 'PUT', body: JSON.stringify({remove_avatar: true})}); - avatar.pending = false; - $('#avatar-file').value = ''; - $('#avatar-save').disabled = true; - render(profile); - notice('Profile picture removed') - } catch (error) { - notice(error.message, true) - } -}); - -$('#copy-avatar').addEventListener('click', () => navigator.clipboard.writeText($('#avatar-url').textContent).then(() => notice('Picture link copied'))); - -function readAsDataURL(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(new Error('could not read the selected file')); - reader.readAsDataURL(file) - }) -} - -// The palette and appearance controls are the components shipped by the theme -// service. See the note in app.js for why this file no longer drives them. - -load().catch(error => notice(error.message, true)); \ No newline at end of file diff --git a/web/roles.html b/web/roles.html deleted file mode 100644 index 8cee3c5..0000000 --- a/web/roles.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - Go Loose · Roles - - - - -
-
- GLGo Loose - -
- -
-
-
-

ROLE MANAGEMENT

-

Who may do
what, per tenant.

-

A role is a named bundle of permissions. Built-in roles are shared by every tenant and cannot be edited; your own tenant can define as many more as it needs.

-
-
-
-
Roles—
- — -
-
- - - -
-
Roles—
-
Built-in—
-
Custom—
-
Assignable to you—
-
- -
-
-

CATALOG

Permissions

- -
-
- - - -
PermissionNameWhat it allowsEnforcedRoles
-
-

Every role can grant any name on this list. The nine marked enforced are the ones this application checks before it acts, so granting one changes what the console lets you do. A permission you add here is reported to client applications through /connect/userinfo, which can then enforce it themselves, but no part of this console tests for it.

-
- -
-
-

PERMISSION CATALOG

Roles

- -
-
- - - -
RoleSlugPermissionsMembersAssignable to you
-
-

You can only create and assign roles that grant no more than you already hold, so managing members can never be used to gain authority.

-
- -
-

HOW THEY ARE USED

Reading a permission

-
-

A membership resolves to exactly one role, and that role resolves to a set of permissions. Every tenant-scoped statement asks the database whether the caller holds the permission it needs, so a tenant-defined role is enforced the same way a built-in one is. Assigning a role only succeeds when every permission it grants is one you already hold, which is what stops handing out a role from becoming a way to gain authority.

-
-
-
- - -
-

New role

- - - - -
Permissions
-

- -
-
- - -
-

Add permission

- - - -

This becomes selectable in every role. Nothing in this console checks for it; it is reported to client applications, which can enforce it themselves.

- -
-
- - - - - - diff --git a/web/roles.js b/web/roles.js deleted file mode 100644 index 2c2b6dd..0000000 --- a/web/roles.js +++ /dev/null @@ -1,226 +0,0 @@ -const state = {tenants: [], roles: [], permissions: [], selectedTenant: ''}; -const $ = selector => document.querySelector(selector); -const $$ = selector => [...document.querySelectorAll(selector)]; -const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' -}[c])); -const notice = (message, error = false) => { - const el = $('#notice'); - el.textContent = message; - el.hidden = false; - el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)'; - clearTimeout(notice.timer); - notice.timer = setTimeout(() => el.hidden = true, 5000) -}; - -async function api(path, options = {}) { - const response = await fetch(path, { - credentials: 'same-origin', ...options, - headers: {'Content-Type': 'application/json', ...(options.headers || {})} - }); - if (response.status === 401) { - location.href = '/login'; - throw new Error('Session expired') - } - const body = response.status === 204 ? null : await response.json(); - if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); - return body -} - -async function loadPermissions() { - try { - state.permissions = (await api('/api/v1/permissions')).permissions - } catch (error) { - notice('You do not have permission to read the permission catalog.', true) - } -} - -async function load() { - const dashboard = await api('/api/v1/dashboard'); - state.tenants = dashboard.tenants; - if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || ''; - // Roles and the permission catalog are fetched from their own endpoints so - // that changing either does not need the whole dashboard reloaded. - await Promise.all([loadRoles(), loadPermissions()]); - render() -} - - -async function loadRoles() { - if (!state.selectedTenant) { - state.roles = []; - return - } - try { - state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles - } catch (error) { - state.roles = []; - notice('You do not have permission to read the roles of this tenant.', true) - } -} - -function selectedTenant() { - return state.tenants.find(tenant => tenant.id === state.selectedTenant) -} - -const canManageRoles = () => Boolean(selectedTenant()?.permissions?.includes('roles.manage')); -// The catalog is shared by every tenant, so changing it needs tenants.manage -// rather than roles.manage. -const canAdministerCatalog = () => Boolean(state.tenants.some(tenant => tenant.permissions?.includes('tenants.manage'))); - -function render() { - const tenant = selectedTenant(); - const custom = state.roles.filter(item => !item.system); - $('#tenant-context').innerHTML = state.tenants.map(item => - ``).join(''); - $('#role-tenant').textContent = tenant ? tenant.name : 'No tenant'; - $('#role-count').textContent = String(state.roles.length); - $('#role-summary').textContent = canManageRoles() ? 'Roles · editable' : 'Roles · read only'; - $('#stat-roles').textContent = state.roles.length; - $('#stat-system').textContent = state.roles.length - custom.length; - $('#stat-custom').textContent = custom.length; - $('#stat-assignable').textContent = state.roles.filter(item => item.assignable).length; - $('#new-role').hidden = !canManageRoles(); - $('#new-permission').hidden = !canAdministerCatalog(); - - $('#role-list').innerHTML = state.roles.map(item => ` - ${escapeHTML(item.name)}${item.system ? ' built-in' : ''}
${escapeHTML(item.description || 'No description')} - ${escapeHTML(item.slug)} - ${item.permissions.map(permission => `${escapeHTML(permission)}`).join('') || 'None'} - ${item.member_count} - ${item.assignable ? 'Yes' : 'No'} -
${item.system ? '' : canManageRoles() ? `` : ''}
- `).join('') || 'No roles available for this tenant.'; - - $('#permission-list').innerHTML = state.permissions.map(item => ` - ${escapeHTML(item.label)}${item.system ? ' built-in' : ''} - ${escapeHTML(item.name)} - ${escapeHTML(item.description || 'No description')} - ${item.enforced ? 'Yes' : 'No'} - ${item.role_count} -
${item.system || !canAdministerCatalog() ? '' : ``}
- `).join('') || 'No permissions in the catalog.' -} - -function permissionCheckboxes(selected) { - const held = new Set(selected || []); - $('#role-permissions').innerHTML = state.permissions.map(item => ``).join('') -} - -function openDialog(item) { - const form = $('#role-form'); - form.reset(); - form.role_id.value = item?.id || ''; - form.slug.value = item?.slug || ''; - form.name.value = item?.name || ''; - form.description.value = item?.description || ''; - // The slug identifies a role for the lifetime of the role, so it is fixed - // once the role exists. - $('#role-slug-label').hidden = Boolean(item); - form.slug.required = !item; - $('#role-dialog-title').textContent = item ? `Edit ${item.name}` : 'New role'; - $('#role-hint').textContent = item - ? 'Saving replaces the permissions of every member holding this role.' - : 'The slug cannot be changed later.'; - permissionCheckboxes(item?.permissions); - $('#role-dialog').showModal() -} - -$('#new-role').addEventListener('click', () => openDialog(null)); -$('#new-permission').addEventListener('click', () => $('#permission-dialog').showModal()); - -$('#permission-form').addEventListener('submit', async event => { - event.preventDefault(); - const form = event.target; - try { - await api('/api/v1/permissions', { - method: 'POST', - body: JSON.stringify({ - name: form.name.value.trim(), - label: form.label.value.trim(), - description: form.description.value.trim() - }) - }); - form.closest('dialog').close(); - form.reset(); - notice('Permission added to the catalog'); - await loadPermissions(); - render() - } catch (error) { - notice(error.message, true) - } -}); -$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close())); - -$('#role-form').addEventListener('submit', async event => { - event.preventDefault(); - const form = event.target; - const permissions = [...form.querySelectorAll('input[name=permissions]:checked')].map(input => input.value); - const id = form.role_id.value; - const body = id - ? {name: form.name.value, description: form.description.value, permissions} - : {tenant_id: state.selectedTenant, slug: form.slug.value, name: form.name.value, description: form.description.value, permissions}; - try { - await api(id ? `/api/v1/roles/${id}` : '/api/v1/roles', { - method: id ? 'PUT' : 'POST', - body: JSON.stringify(body) - }); - form.closest('dialog').close(); - notice(id ? 'Role updated' : 'Role created'); - await loadRoles(); - render() - } catch (error) { - notice(error.message, true) - } -}); - -document.addEventListener('click', async event => { - const edit = event.target.dataset.edit; - if (edit) { - openDialog(state.roles.find(item => item.id === edit)); - return - } - const removePermission = event.target.dataset.deletePermission; - if (removePermission) { - if (!confirm(`Delete the permission "${removePermission}"?`)) return; - try { - await api(`/api/v1/permissions/${encodeURIComponent(removePermission)}`, {method: 'DELETE'}); - notice('Permission deleted'); - await loadPermissions(); - render() - } catch (error) { - notice(error.message, true) - } - return - } - const remove = event.target.dataset.delete; - if (remove) { - const item = state.roles.find(role => role.id === remove); - if (!confirm(`Delete the role "${item.name}"?`)) return; - try { - await api(`/api/v1/roles/${remove}`, {method: 'DELETE'}); - notice('Role deleted'); - await loadRoles(); - render() - } catch (error) { - notice(error.message, true) - } - } -}); - -// The palette and appearance controls are the components shipped by the theme -// service. See the note in app.js for why this file no longer drives them. -$('#tenant-context').addEventListener('change', async event => { - state.selectedTenant = event.target.value; - await loadRoles(); - render() -}); - -load().catch(error => notice(error.message, true)); diff --git a/web/style.css b/web/style.css index 41c21f1..17d4cad 100644 --- a/web/style.css +++ b/web/style.css @@ -1,56 +1,18 @@ -:root{--bg:var(--bn-bg,#16161e);--surface:var(--bn-surface,#1f2335);--surface2:var(--bn-surface-raised,#24283b);--text:var(--bn-text,#c0caf5);--muted:var(--bn-muted,#7aa2f7);--accent:var(--bn-accent,#7dcfff);--accent2:var(--bn-secondary,#bb9af7);--good:var(--bn-good,#9ece6a);--danger:var(--bn-danger,#f7768e);--border:var(--bn-border,#3b4261);--shadow:rgba(0,0,0,.35)} -[data-theme=catppuccin]{--bg:var(--bn-bg,#1e1e2e);--surface:var(--bn-surface,#181825);--surface2:var(--bn-surface-raised,#313244);--text:var(--bn-text,#cdd6f4);--muted:var(--bn-muted,#89b4fa);--accent:var(--bn-accent,#94e2d5);--accent2:var(--bn-secondary,#cba6f7);--good:var(--bn-good,#a6e3a1);--danger:var(--bn-danger,#f38ba8);--border:var(--bn-border,#45475a)} -[data-theme=tomorrow]{--bg:var(--bn-bg,#1d1f21);--surface:var(--bn-surface,#282a2e);--surface2:var(--bn-surface-raised,#373b41);--text:var(--bn-text,#c5c8c6);--muted:var(--bn-muted,#81a2be);--accent:var(--bn-accent,#8abeb7);--accent2:var(--bn-secondary,#b294bb);--good:var(--bn-good,#b5bd68);--danger:var(--bn-danger,#cc6666);--border:var(--bn-border,#4d5057)} -[data-theme=kanagawa]{--bg:var(--bn-bg,#1f1f28);--surface:var(--bn-surface,#2a2a37);--surface2:var(--bn-surface-raised,#363646);--text:var(--bn-text,#dcd7ba);--muted:var(--bn-muted,#7e9cd8);--accent:var(--bn-accent,#7fb4ca);--accent2:var(--bn-secondary,#957fb8);--good:var(--bn-good,#98bb6c);--danger:var(--bn-danger,#e46876);--border:var(--bn-border,#54546d)} -[data-mode=light][data-theme=tokyo]{--bg:var(--bn-bg,#e6e7ed);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#dfe2ed);--text:var(--bn-text,#343b58);--muted:var(--bn-muted,#34548a);--accent:var(--bn-accent,#0f4b6e);--accent2:var(--bn-secondary,#5a4a78);--good:var(--bn-good,#485e30);--danger:var(--bn-danger,#8c4351);--border:var(--bn-border,#b4b5c3);--shadow:rgba(52,59,88,.15)} -[data-mode=light][data-theme=catppuccin]{--bg:var(--bn-bg,#eff1f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e6e9ef);--text:var(--bn-text,#4c4f69);--muted:var(--bn-muted,#1e66f5);--accent:var(--bn-accent,#179299);--accent2:var(--bn-secondary,#8839ef);--good:var(--bn-good,#40a02b);--danger:var(--bn-danger,#d20f39);--border:var(--bn-border,#bcc0cc);--shadow:rgba(76,79,105,.15)} -[data-mode=light][data-theme=tomorrow]{--bg:var(--bn-bg,#f5f5f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e8e8e8);--text:var(--bn-text,#4d4d4c);--muted:var(--bn-muted,#4271ae);--accent:var(--bn-accent,#3e999f);--accent2:var(--bn-secondary,#8959a8);--good:var(--bn-good,#718c00);--danger:var(--bn-danger,#c82829);--border:var(--bn-border,#c9c9c9);--shadow:rgba(77,77,76,.15)} -[data-mode=light][data-theme=kanagawa]{--bg:var(--bn-bg,#f2ecde);--surface:var(--bn-surface,#fffaf0);--surface2:var(--bn-surface-raised,#e7dba9);--text:var(--bn-text,#545464);--muted:var(--bn-muted,#4d699b);--accent:var(--bn-accent,#4d7f8c);--accent2:var(--bn-secondary,#624c83);--good:var(--bn-good,#6f894e);--danger:var(--bn-danger,#c34043);--border:var(--bn-border,#c8c093);--shadow:rgba(84,84,100,.15)} +:root{--bg:#16161e;--surface:#1f2335;--surface2:#24283b;--text:#c0caf5;--muted:#7aa2f7;--accent:#7dcfff;--accent2:#bb9af7;--good:#9ece6a;--danger:#f7768e;--border:#3b4261;--shadow:rgba(0,0,0,.35)} +[data-theme=catppuccin]{--bg:#1e1e2e;--surface:#181825;--surface2:#313244;--text:#cdd6f4;--muted:#89b4fa;--accent:#94e2d5;--accent2:#cba6f7;--good:#a6e3a1;--danger:#f38ba8;--border:#45475a} +[data-theme=tomorrow]{--bg:#1d1f21;--surface:#282a2e;--surface2:#373b41;--text:#c5c8c6;--muted:#81a2be;--accent:#8abeb7;--accent2:#b294bb;--good:#b5bd68;--danger:#cc6666;--border:#4d5057} +[data-theme=kanagawa]{--bg:#1f1f28;--surface:#2a2a37;--surface2:#363646;--text:#dcd7ba;--muted:#7e9cd8;--accent:#7fb4ca;--accent2:#957fb8;--good:#98bb6c;--danger:#e46876;--border:#54546d} +[data-mode=light][data-theme=tokyo]{--bg:#e6e7ed;--surface:#fff;--surface2:#dfe2ed;--text:#343b58;--muted:#34548a;--accent:#0f4b6e;--accent2:#5a4a78;--good:#485e30;--danger:#8c4351;--border:#b4b5c3;--shadow:rgba(52,59,88,.15)} +[data-mode=light][data-theme=catppuccin]{--bg:#eff1f5;--surface:#fff;--surface2:#e6e9ef;--text:#4c4f69;--muted:#1e66f5;--accent:#179299;--accent2:#8839ef;--good:#40a02b;--danger:#d20f39;--border:#bcc0cc;--shadow:rgba(76,79,105,.15)} +[data-mode=light][data-theme=tomorrow]{--bg:#f5f5f5;--surface:#fff;--surface2:#e8e8e8;--text:#4d4d4c;--muted:#4271ae;--accent:#3e999f;--accent2:#8959a8;--good:#718c00;--danger:#c82829;--border:#c9c9c9;--shadow:rgba(77,77,76,.15)} +[data-mode=light][data-theme=kanagawa]{--bg:#f2ecde;--surface:#fffaf0;--surface2:#e7dba9;--text:#545464;--muted:#4d699b;--accent:#4d7f8c;--accent2:#624c83;--good:#6f894e;--danger:#c34043;--border:#c8c093;--shadow:rgba(84,84,100,.15)} *{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} .docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)} main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)} -.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:flex-start}.card h3{margin:6px 0 5px;width:100%}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);margin:0;width:100%;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-action{margin-top:14px;align-self:flex-start;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:grid;gap:7px;width:100%;margin-top:auto;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}.meta>div{display:flex;align-items:baseline;gap:14px;min-width:0}.meta span{flex:none;text-transform:uppercase;letter-spacing:.09em;font-size:10px}.meta b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px ui-monospace,monospace;color:var(--text)}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px} +.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px}.card h3{margin:0 0 5px}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);min-height:24px}.card-action{margin-top:16px;width:100%;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:flex;justify-content:space-between;margin-top:18px;color:var(--muted);font-size:12px}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px} dialog{color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 30px 100px rgba(0,0,0,.6);width:min(520px,92vw);padding:24px}dialog::backdrop{background:rgba(8,8,15,.74);backdrop-filter:blur(4px)}dialog form{display:grid;gap:16px}.dialog-head{display:flex;align-items:center;justify-content:space-between}.dialog-head h3{margin:0;font-size:22px}.close{border:0;background:transparent;color:var(--muted);font-size:28px;cursor:pointer}dialog label{display:grid;gap:7px;font-size:13px;color:var(--muted)}textarea{resize:vertical}.segmented{display:flex;gap:20px}.segmented label{display:flex;align-items:center;grid-template-columns:auto auto}.secret{display:flex;gap:8px;align-items:center;background:var(--bg);padding:12px;border-radius:8px}.secret code{overflow-wrap:anywhere;color:var(--good);flex:1} .check-list{display:grid;gap:8px}.check-list label{display:flex;grid-template-columns:auto 1fr;align-items:center}.client-terminal h1{font-size:clamp(38px,6vw,64px);line-height:1;margin:10px 0 32px;letter-spacing:-.05em}.client-terminal h1 span{color:var(--accent2)} .login-page{display:grid;place-items:center;min-height:100vh;overflow:hidden}.login-glow{position:fixed;width:600px;height:600px;border-radius:50%;background:var(--accent2);filter:blur(180px);opacity:.13}.terminal{width:min(920px,92vw);margin:40px;position:relative;background:color-mix(in srgb,var(--surface) 94%,black);border:1px solid var(--border);border-radius:10px;box-shadow:0 40px 120px rgba(0,0,0,.55);overflow:hidden;font-family:ui-monospace,SFMono-Regular,monospace}.terminal-bar{height:44px;background:var(--surface2);display:flex;align-items:center;gap:8px;padding:0 15px}.terminal-bar span{width:11px;height:11px;border-radius:50%;background:var(--danger)}.terminal-bar span:nth-child(2){background:#e0af68}.terminal-bar span:nth-child(3){background:var(--good)}.terminal-bar strong{position:absolute;left:50%;transform:translateX(-50%);color:var(--muted);font-size:12px}.terminal-body{padding:35px}.ascii{color:var(--accent);font-size:clamp(6px,1vw,12px);line-height:1.2;overflow:hidden}.prompt{color:var(--good)}.muted{color:var(--muted);max-width:650px;line-height:1.6}.terminal-login{display:inline-flex;gap:10px;text-decoration:none;margin:20px 0 10px}.dev-login{display:block;color:var(--muted);font-size:11px}.cursor{color:var(--accent);animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.docs-page #swagger-ui{max-width:1200px;margin:30px auto}.docs-page .swagger-ui{filter:none} .password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em} .installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)} @media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}} -.identity{display:inline-flex;align-items:center;gap:8px;text-decoration:none}.identity img,.avatar{border-radius:50%;object-fit:cover;display:block;flex:none}.profile-hero{min-height:0;padding:56px 0 34px}.profile-hero h1{font-size:clamp(40px,5vw,64px)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin-top:34px}.profile-card{display:grid;gap:16px;align-content:start}.profile-card h2{margin:0;font-size:20px;letter-spacing:-.02em}.profile-wide{grid-column:1/-1}.profile-card form{display:grid;gap:14px}.profile-card label{display:grid;gap:7px;font-size:13px;color:var(--muted)}.profile-actions{display:flex;gap:10px;flex-wrap:wrap}.profile-actions button{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions .ghost{background:transparent;border:1px solid var(--border);color:var(--text)}.profile-card button[type=submit]{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer;justify-self:start}.avatar-preview{display:grid;place-items:center;width:112px;height:112px;border-radius:50%;overflow:hidden;background:var(--surface2);border:1px solid var(--border)}.avatar-preview img{width:100%;height:100%;object-fit:cover}.avatar-initials{font:750 34px ui-monospace,monospace;color:var(--accent)}.avatar-empty{display:inline-grid;place-items:center;width:28px;height:28px;vertical-align:middle;background:var(--surface2);border:1px solid var(--border);color:var(--accent);font:700 11px ui-monospace,monospace}.facts{display:grid;gap:10px;margin:4px 0 0}.facts div{display:flex;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);padding-bottom:8px;font-size:13px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right}.capabilities{display:flex;flex-wrap:wrap;gap:6px}.chip{font:600 11px ui-monospace,monospace;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 6px} - -/* ---------------------------------------------------------- account menu -- - The header account control: a trigger showing who is signed in, opening a - small menu with the two things a person can do about their own account. It - replaces the separate sign-out button, so the identity and the action live in - one place instead of two controls that mean the same thing. */ -.account{position:relative;margin-left:2px} -.account-trigger{display:inline-flex;align-items:center;gap:8px;padding:5px 9px 5px 6px;background:transparent;border:1px solid var(--border);border-radius:8px;color:var(--text);cursor:pointer;font:inherit;font-size:13px} -.account-trigger:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))} -.account-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px} -.account-trigger .avatar{width:24px;height:24px} -.account-name{font-weight:650;max-width:11rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.account-caret{color:var(--muted);font-size:10px} -.account-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:40;min-width:15rem;padding:6px;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 14px 34px rgba(0,0,0,.28)} -.account-menu[hidden]{display:none} -.account-identity{padding:9px 10px 11px;border-bottom:1px solid var(--border);margin-bottom:6px} -.account-identity strong{display:block;font-size:13px} -.account-identity span{display:block;color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.account-menu form{margin:0} -.account-menu a,.account-menu button{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;background:transparent;border:0;border-radius:7px;color:var(--text);font:inherit;font-size:13px;text-align:left;text-decoration:none;cursor:pointer} -.account-menu a:hover,.account-menu button:hover{background:var(--surface2)} -.account-menu a:focus-visible,.account-menu button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} -.account-note{padding:7px 10px 4px;color:var(--muted);font-size:11px;line-height:1.45} - -/* Row actions inside a table. The existing .card-action is a full-width block - button meant for the foot of a card, which stacked two of them in a narrow - cell and made the row taller than its content. These are inline and sit on the - text baseline. */ -.table-actions{display:flex;gap:6px;justify-content:flex-end;white-space:nowrap} -.table-action{padding:5px 10px;background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--text);font:inherit;font-size:12px;cursor:pointer} -.table-action:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));color:var(--accent)} -.table-action:focus-visible{outline:2px solid var(--accent);outline-offset:2px} -.table-action.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,var(--border))} -.table-action.danger:hover{background:color-mix(in srgb,var(--danger) 12%,transparent)} -/* A slug, prefix or permission name is an identifier: wrapping it across two lines - makes one row taller than its neighbours for no benefit. */ -.table-wrap td code{white-space:nowrap}