From b011701522b1650af0e22297916f6c09af714e1e Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 3 Oct 2026 00:45:34 +0200 Subject: [PATCH 01/10] adding certificate --- docker-compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 9636f50..e607cf7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -31,6 +31,9 @@ services: 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: From 84b64310fe879731e8533d77a8ff77284f3085c9 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 3 Oct 2026 04:41:03 +0200 Subject: [PATCH 02/10] doc: modified client-login.md --- docs/client-login.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/client-login.md b/docs/client-login.md index 6ba2891..509780d 100644 --- a/docs/client-login.md +++ b/docs/client-login.md @@ -129,6 +129,31 @@ The authorization code expires after two minutes and can be used once. Redirect - The middleware checks Go Loose on every request for immediate revocation. Deploy Go Loose highly available and use a bounded HTTP timeout. - API-key authentication and browser-user authentication are independent. Use `client.Middleware` for service/API keys and `BrowserAuth.Middleware` for logged-in users. +## 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` `403 your account does not have access to this application` means authentication succeeded but the user lacks an application grant. In Go Loose, open **Users**, select the user, choose **Manage**, and enable the target application. A missing or incorrect token header returns `401`, not `403`. From b984c8a90c1bfe2f7b6a233a5dc7323b639a7331 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 3 Oct 2026 05:17:06 +0200 Subject: [PATCH 03/10] feat: read the identity domain from the environment Compose hardcoded auth.dev, so rehearsing a deployment meant editing a tracked file and losing the change on the next pull. Read the three domain values from the environment with the default kept at auth.dev, so a plain docker compose up behaves exactly as before. --- docker-compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index e607cf7..d30293b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,9 +23,9 @@ services: condition: service_healthy environment: GO_LOOSE_ADDR: ":8080" - GO_LOOSE_BASE_URL: "https://auth.dev" - GO_LOOSE_AUTH_DOMAIN: "auth.dev" - GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.dev/auth/callback" + GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-https://auth.dev}" + GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-auth.dev}" + GO_LOOSE_OIDC_REDIRECT_URL: "${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" From 318fb510858d86beac469ecc1f72a3b750094f64 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 4 Oct 2026 11:02:23 +0200 Subject: [PATCH 04/10] fix: changing url --- docker-compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index d30293b..5c159d9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,9 +23,9 @@ services: condition: service_healthy environment: GO_LOOSE_ADDR: ":8080" - GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-https://auth.dev}" - GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-auth.dev}" - GO_LOOSE_OIDC_REDIRECT_URL: "${GO_LOOSE_OIDC_REDIRECT_URL:-https://auth.dev/auth/callback}" + GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-https://auth.urpi.be}" + GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-auth.urpi.be}" + GO_LOOSE_OIDC_REDIRECT_URL: "${GO_LOOSE_OIDC_REDIRECT_URL:-https://auth.urpi.be/auth/callback}" GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable" GO_LOOSE_SESSION_SECRET: "local-development-secret-change-me-now" GO_LOOSE_DEV_LOGIN: "false" From 6319da650ce30c35088ffaa21235484cc21b4f00 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 4 Oct 2026 12:24:13 +0200 Subject: [PATCH 05/10] fix installer --- .gitignore | 1 + Makefile | 8 +- README.md | 8 + client/webauth.go | 18 + docker-compose.yaml | 4 +- docs/client-login.md | 20 + docs/coding-standards.md | 1 + docs/roles.md | 53 ++ internal/avatar/avatar.go | 143 +++++ internal/avatar/avatar_test.go | 172 ++++++ .../migrations/0005_roles_and_profile.sql | 45 ++ internal/role/role.go | 175 ++++++ internal/role/role_test.go | 135 +++++ internal/server/clientauth.go | 30 +- internal/server/profile.go | 143 +++++ internal/server/profile_test.go | 194 +++++++ internal/server/server.go | 53 +- internal/server/tenantauth.go | 3 + internal/server/useradmin.go | 38 +- internal/store/clientauth.go | 79 ++- internal/store/installation.go | 26 +- internal/store/integration_test.go | 510 ++++++++++++++++++ internal/store/profile.go | 150 ++++++ internal/store/store.go | 40 +- web/app.js | 55 +- web/index.html | 15 +- web/login.html | 10 +- web/openapi.json | 103 +++- web/profile.html | 79 +++ web/profile.js | 166 ++++++ web/style.css | 1 + 31 files changed, 2391 insertions(+), 87 deletions(-) create mode 100644 docs/roles.md create mode 100644 internal/avatar/avatar.go create mode 100644 internal/avatar/avatar_test.go create mode 100644 internal/database/migrations/0005_roles_and_profile.sql create mode 100644 internal/role/role.go create mode 100644 internal/role/role_test.go create mode 100644 internal/server/profile.go create mode 100644 internal/server/profile_test.go create mode 100644 internal/store/integration_test.go create mode 100644 internal/store/profile.go create mode 100644 web/profile.html create mode 100644 web/profile.js diff --git a/.gitignore b/.gitignore index cf3bd3c..afa1b75 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ bin/ coverage.out google_secrets.json +data/ \ No newline at end of file diff --git a/Makefile b/Makefile index 1407b5e..a0ace0d 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true export -.PHONY: build test run compose-up compose-down fmt vet +.PHONY: build test integration-test run compose-up compose-down fmt vet build: go build ./... @@ -20,6 +20,12 @@ build: test: go test -race ./... +# Integration tests truncate every table in the target database, so point them at +# a scratch database rather than one holding real data. +integration-test: + @test -n "$(GO_LOOSE_TEST_DATABASE_URL)" || { echo "set GO_LOOSE_TEST_DATABASE_URL to a scratch database"; exit 1; } + go test -race ./internal/store/ -count=1 + run: go run ./cmd/go-loose diff --git a/README.md b/README.md index 57d8aea..c38b365 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,15 @@ Production must place Go Loose behind TLS, use a secret manager, set `GO_LOOSE_B - [First-run installation, waiting room, and demo accounts](docs/installation.md) - [Go middleware integration](docs/integration.md) - [Client application login](docs/client-login.md) +- [Tenant roles, capabilities, and profile pictures](docs/roles.md) - [Database user authentication and local credentials](docs/password-authentication.md) - [Operations, migrations, and OIDC](docs/operations.md) - [Coding standards](docs/coding-standards.md) +## Profile + +Every management session can open `/profile` to set a display name and a profile picture. Pictures are stored in PostgreSQL and served from a public, unguessable URL that client applications can embed directly. Display names stop being overwritten by the identity provider once the user customizes them. See [roles and capabilities](docs/roles.md). + ## Development ```bash @@ -77,3 +82,6 @@ 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 integration-test` runs the store integration tests against `GO_LOOSE_TEST_DATABASE_URL`. Those tests drop every table in the target database, so point them at a scratch database. + diff --git a/client/webauth.go b/client/webauth.go index 0a9c011..36cbde5 100644 --- a/client/webauth.go +++ b/client/webauth.go @@ -41,10 +41,28 @@ type User struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` TenantID string `json:"tenant_id"` TenantSlug string `json:"tenant_slug"` ApplicationID string `json:"application_id"` Application string `json:"application"` + // Role is the user's membership role in the owning tenant, for example + // user, operator, developer, admin, or owner. + Role string `json:"role"` + // Permissions lists the capability names granted by Role, such as + // "console.read", "keys.manage", or "profile.edit". Gate application + // features on these instead of comparing role names. + Permissions []string `json:"permissions"` +} + +// Can reports whether the role behind this session grants a capability. +func (u User) Can(permission string) bool { + for _, granted := range u.Permissions { + if granted == permission { + return true + } + } + return false } type userContextKey struct{} diff --git a/docker-compose.yaml b/docker-compose.yaml index 5c159d9..9a1ad53 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: POSTGRES_USER: goloose POSTGRES_PASSWORD: goloose volumes: - - postgres-data2:/var/lib/postgresql/data + - ./data/db:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"] interval: 5s @@ -48,8 +48,6 @@ services: aliases: - go-loose -volumes: - postgres-data2: networks: go_loose_network: local_dev_edge: diff --git a/docs/client-login.md b/docs/client-login.md index 509780d..e31b466 100644 --- a/docs/client-login.md +++ b/docs/client-login.md @@ -120,6 +120,26 @@ 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 +{ + "sub": "9f0f...", + "email": "ada@example.com", + "display_name": "Ada Lovelace", + "avatar_url": "https://loose.example.com/api/v1/avatars/kZ3...", + "role": "developer", + "permissions": ["console.read", "profile.edit", "applications.manage", "keys.manage", "contracts.manage"], + "can": {"contracts.manage": true, "users.manage": false} +} +``` + +`avatar_url` is public by design, so it can be embedded directly with a plain image tag. It is an empty string when the user has not uploaded a picture. Read `can` rather than checking `role` by name; the client package builds it from the same matrix the console uses. See [roles and capabilities](roles.md). + +A user with no membership in the application's tenant still authenticates, but the role falls back to `user` with only `profile.edit`. + ## Security and availability - Use TLS for both Go Loose and the client application. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index b7f71df..87a9a03 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -8,6 +8,7 @@ - Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys. - Return explicit errors. Runtime authorization must fail closed. - Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction. +- Derive authorization from the matrix in `internal/role` instead of hardcoding role names. Pass `role.Grants(...)` into SQL, use `role.Allows` in handlers, and render controls from the capability lists the server returns. - Use cryptographically secure randomness for credentials and constant-time comparison for signatures. - Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`. diff --git a/docs/roles.md b/docs/roles.md new file mode 100644 index 0000000..366f9b1 --- /dev/null +++ b/docs/roles.md @@ -0,0 +1,53 @@ +# Roles and capabilities + +Every tenant role maps to a fixed set of capabilities in `internal/role`. The console reads the capability list from the server instead of hardcoding role names, and store queries pass `role.Grants(...)` into SQL so authorization and rendering cannot drift apart. + +## Capability matrix + +| Capability | owner | admin | developer | operator | viewer | user | +| --- | --- | --- | --- | --- | --- | --- | +| `console.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 | no | no | no | no | +| `profile.edit` | yes | yes | yes | yes | yes | yes | + +Intended use: + +- **owner** — full control of the tenant, including role assignment. +- **admin** — manages applications, keys, contracts, and members. +- **developer** — ships service contracts and API keys, but cannot change membership. +- **operator** — runs existing applications: rotates keys and imports contracts. +- **viewer** — read-only console access. +- **user** — self-service only. No console access; the session lands on `/profile`. + +`user` members without any tenant membership still see the waiting room. Those with a membership are redirected from `/` to `/profile`, because the dashboard requires `console.read`. + +## Where the checks live + +Store methods enforce capabilities inside SQL (`role = ANY($n)` with a text array) or inside the same transaction as the write. The server additionally filters and labels UI controls with `role.Allows`. Both layers read the same matrix, so adding a capability or a role is a one-line change in `internal/role/role.go`. + +`role.Role` values are also validated by a database check constraint on `memberships.role`, so an unknown role cannot be stored even if a caller bypasses the Go layer. + +New roles must be added to the check constraint in a new migration as well as to the matrix; existing deployments keep the constraint in `internal/database/migrations/0005_roles_and_profile.sql` for historical reasons. + +## Display names and profile pictures + +Every management session can open `/profile` and set a display name and a profile picture, independent of tenant role. + +Setting the display name flags the user as `name_customized`, which stops the identity provider from overwriting it on the next login. The row is still matched by email on every login, so customization never breaks sign-in. + +Pictures are stored as bytes in PostgreSQL with a random 256-bit key and served from the public `GET /api/v1/avatars/{key}` route. The URL is unguessable and carries no user identity, so applications can embed it directly: + +```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 and permissions + +`/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application. `client.User.Can` exposes the same checks to Go code, so a client can adapt its UI without duplicating the matrix. + +A user with no membership in the application's tenant still authenticates, but the role falls back to `user` with only `profile.edit`. \ No newline at end of file diff --git a/internal/avatar/avatar.go b/internal/avatar/avatar.go new file mode 100644 index 0000000..eeb9cf3 --- /dev/null +++ b/internal/avatar/avatar.go @@ -0,0 +1,143 @@ +// Package avatar validates and addresses profile pictures stored in PostgreSQL. +// +// Images are kept as their uploaded bytes. Go Loose never re-encodes them; it +// sniffs the format, bounds the decoded dimensions to reject decompression +// bombs, and serves the bytes back with the sniffed content type behind an +// unguessable key so that other applications can embed the picture with a plain +// image tag. +package avatar + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "strings" +) + +const ( + // MaxBytes bounds an uploaded picture. Base64 inflates it by 4/3, so a + // request carrying an avatar must allow roughly MaxBytes*4/3 bytes. + MaxBytes = 2 << 20 + // MaxDimension bounds decoded pixels so that a small upload cannot expand + // into an unreasonable image when a client renders it. + MaxDimension = 4096 +) + +var ( + ErrEmpty = errors.New("the picture is empty") + ErrTooLarge = fmt.Errorf("the picture must be %d KiB or smaller", MaxBytes/1024) + ErrUnsupported = errors.New("the picture must be a JPEG, PNG, or GIF image") + ErrDimensions = fmt.Errorf("the picture must not exceed %d by %d pixels", MaxDimension, MaxDimension) + ErrEncoding = errors.New("the picture is not valid base64") +) + +// allowed maps the format names reported by image.DecodeConfig to content types. +// Only the decoders registered above are accepted, so an unsupported upload +// fails validation instead of being stored and served. +var allowed = map[string]string{ + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", +} + +// Parse decodes a base64 payload, with or without a data URL prefix, and +// returns the image bytes together with the sniffed content type. +func Parse(value string) ([]byte, string, error) { + payload, declared := trimDataURL(value) + if payload == "" { + return nil, "", ErrEmpty + } + if base64.StdEncoding.DecodedLen(len(payload)) > MaxBytes { + return nil, "", ErrTooLarge + } + data, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, "", ErrEncoding + } + contentType, err := Inspect(data) + if err != nil { + return nil, "", err + } + if declared != "" && !strings.EqualFold(declared, contentType) { + return nil, "", ErrUnsupported + } + return data, contentType, nil +} + +// Inspect validates raw image bytes and returns their content type. +func Inspect(data []byte) (string, error) { + if len(data) == 0 { + return "", ErrEmpty + } + if len(data) > MaxBytes { + return "", ErrTooLarge + } + decoded, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return "", ErrUnsupported + } + contentType, known := allowed[format] + if !known { + return "", ErrUnsupported + } + if decoded.Width > MaxDimension || decoded.Height > MaxDimension { + return "", ErrDimensions + } + return contentType, nil +} + +// NewKey returns an unguessable locator for a stored picture. The key is a +// capability-free random value: it authorizes reading a picture that is +// intended to be public, not access to anything else about the user. +func NewKey() (string, error) { + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + return "", fmt.Errorf("generate avatar key: %w", err) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +// ValidKey reports whether a key has the shape produced by NewKey, which keeps +// obviously malformed lookups out of the database. +func ValidKey(key string) bool { + if len(key) != 43 { + return false + } + _, err := base64.RawURLEncoding.DecodeString(key) + return err == nil +} + +// ETag returns a strong validator derived from the stored bytes so that clients +// and proxies can cache the picture and revalidate cheaply. +func ETag(data []byte) string { + sum := sha256.Sum256(data) + return `"` + hex.EncodeToString(sum[:16]) + `"` +} + +func trimDataURL(value string) (payload, mediaType string) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(strings.ToLower(value), "data:") { + return value, "" + } + comma := strings.IndexByte(value, ',') + if comma < 0 { + return "", "" + } + header := value[len("data:"):comma] + parameters := strings.Split(header, ";") + mediaType = strings.ToLower(strings.TrimSpace(parameters[0])) + for _, parameter := range parameters[1:] { + if strings.EqualFold(strings.TrimSpace(parameter), "base64") { + return strings.TrimSpace(value[comma+1:]), mediaType + } + } + return "", "" +} diff --git a/internal/avatar/avatar_test.go b/internal/avatar/avatar_test.go new file mode 100644 index 0000000..4f57823 --- /dev/null +++ b/internal/avatar/avatar_test.go @@ -0,0 +1,172 @@ +package avatar + +import ( + "bytes" + "encoding/base64" + "errors" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "testing" +) + +func samplePNG(t *testing.T, width, height int) []byte { + t.Helper() + canvas := image.NewRGBA(image.Rect(0, 0, width, height)) + canvas.Set(0, 0, color.RGBA{R: 125, G: 207, B: 255, A: 255}) + buffer := &bytes.Buffer{} + if err := png.Encode(buffer, canvas); err != nil { + t.Fatalf("encode png: %v", err) + } + return buffer.Bytes() +} + +func encode(t *testing.T, format string, width, height int) []byte { + t.Helper() + canvas := image.NewRGBA(image.Rect(0, 0, width, height)) + buffer := &bytes.Buffer{} + var err error + switch format { + case "jpeg": + err = jpeg.Encode(buffer, canvas, nil) + case "gif": + err = gif.Encode(buffer, canvas, nil) + default: + err = png.Encode(buffer, canvas) + } + if err != nil { + t.Fatalf("encode %s: %v", format, err) + } + return buffer.Bytes() +} + +func TestParseAcceptsSupportedFormats(t *testing.T) { + for _, format := range []string{"png", "jpeg", "gif"} { + t.Run(format, func(t *testing.T) { + raw := encode(t, format, 8, 8) + data, contentType, err := Parse(base64.StdEncoding.EncodeToString(raw)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if !bytes.Equal(data, raw) { + t.Fatal("Parse() did not return the uploaded bytes") + } + if contentType != "image/"+map[string]string{"png": "png", "jpeg": "jpeg", "gif": "gif"}[format] { + t.Fatalf("content type = %q", contentType) + } + }) + } +} + +func TestParseAcceptsDataURL(t *testing.T) { + raw := samplePNG(t, 4, 4) + dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw) + data, contentType, err := Parse(dataURL) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(data) != len(raw) || contentType != "image/png" { + t.Fatalf("Parse() = %d bytes of %q", len(data), contentType) + } +} + +func TestParseRejectsMismatchedDataURLMediaType(t *testing.T) { + raw := samplePNG(t, 4, 4) + dataURL := "data:image/gif;base64," + base64.StdEncoding.EncodeToString(raw) + if _, _, err := Parse(dataURL); !errors.Is(err, ErrUnsupported) { + t.Fatalf("error = %v, want %v", err, ErrUnsupported) + } +} + +func TestParseRejectsNonBase64DataURL(t *testing.T) { + raw := samplePNG(t, 4, 4) + dataURL := "data:image/png," + base64.StdEncoding.EncodeToString(raw) + if _, _, err := Parse(dataURL); !errors.Is(err, ErrEmpty) { + t.Fatalf("error = %v, want %v", err, ErrEmpty) + } +} + +func TestParseRejectsMalformedAndUnsupportedInput(t *testing.T) { + tests := []struct { + name string + value string + want error + }{ + {name: "empty", value: " ", want: ErrEmpty}, + {name: "not base64", value: "!!!not base64!!!", want: ErrEncoding}, + {name: "plain text", value: base64.StdEncoding.EncodeToString([]byte("hello world, not an image")), want: ErrUnsupported}, + {name: "svg", value: base64.StdEncoding.EncodeToString([]byte(``)), 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/database/migrations/0005_roles_and_profile.sql b/internal/database/migrations/0005_roles_and_profile.sql new file mode 100644 index 0000000..3fdc4a5 --- /dev/null +++ b/internal/database/migrations/0005_roles_and_profile.sql @@ -0,0 +1,45 @@ +-- +goose Up +ALTER TABLE memberships DROP CONSTRAINT memberships_role_check; +ALTER TABLE memberships + ADD CONSTRAINT memberships_role_check + CHECK (role IN ('owner', 'admin', 'developer', 'operator', 'viewer', 'user')); + +ALTER TABLE users + ADD COLUMN avatar_key text UNIQUE, + ADD COLUMN avatar bytea, + ADD COLUMN avatar_content_type text, + ADD COLUMN avatar_updated_at timestamptz, + ADD COLUMN name_customized boolean NOT NULL DEFAULT false, + ADD COLUMN profile_updated_at timestamptz; + +-- The picture columns move together so that a key can never address a missing +-- or mismatched image. +ALTER TABLE users + ADD CONSTRAINT users_avatar_complete CHECK ( + (avatar IS NULL AND avatar_key IS NULL AND avatar_content_type IS NULL AND avatar_updated_at IS NULL) + OR (avatar IS NOT NULL AND avatar_key IS NOT NULL AND avatar_content_type IS NOT NULL AND avatar_updated_at IS NOT NULL) + ); + +ALTER TABLE users + ADD CONSTRAINT users_avatar_content_type_known CHECK ( + avatar_content_type IS NULL OR avatar_content_type IN ('image/jpeg', 'image/png', 'image/gif') + ); + +-- +goose Down +UPDATE memberships SET role = 'viewer' WHERE role IN ('developer', 'operator', 'user'); + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_avatar_content_type_known, + DROP CONSTRAINT IF EXISTS users_avatar_complete; + +ALTER TABLE users + DROP COLUMN IF EXISTS profile_updated_at, + DROP COLUMN IF EXISTS name_customized, + DROP COLUMN IF EXISTS avatar_updated_at, + DROP COLUMN IF EXISTS avatar_content_type, + DROP COLUMN IF EXISTS avatar, + DROP COLUMN IF EXISTS avatar_key; + +ALTER TABLE memberships DROP CONSTRAINT memberships_role_check; +ALTER TABLE memberships + ADD CONSTRAINT memberships_role_check CHECK (role IN ('owner', 'admin', 'viewer')); \ No newline at end of file diff --git a/internal/role/role.go b/internal/role/role.go new file mode 100644 index 0000000..c4f3bda --- /dev/null +++ b/internal/role/role.go @@ -0,0 +1,175 @@ +// Package role defines the tenant membership roles and the single permission +// matrix that authorizes console and management behavior. +// +// Roles are stored in memberships.role. Authorization decisions must derive +// from Grants or Allows so that SQL role checks and Go checks cannot drift +// apart. +package role + +// Role is a tenant membership role. +type Role string + +const ( + // Owner has full control of a tenant, including tenant-level settings. + Owner Role = "owner" + // Admin manages applications, credentials, contracts, and members. + Admin Role = "admin" + // Developer manages applications, credentials, and contracts. + Developer Role = "developer" + // Operator issues and revokes credentials and imports contracts. + Operator Role = "operator" + // Viewer reads the console without changing anything. + Viewer Role = "viewer" + // User is a self-service member. It carries no console access and exists so + // that end users of client applications still hold a tenant role that + // applications can read through /connect/userinfo. + User Role = "user" +) + +// Permission is a capability granted by a role. +type Permission string + +const ( + ReadConsole Permission = "console.read" + ReadUsers Permission = "users.read" + ManageApplications Permission = "applications.manage" + ManageKeys Permission = "keys.manage" + ManageContracts Permission = "contracts.manage" + ManageUsers Permission = "users.manage" + ManageTenants Permission = "tenants.manage" + EditProfile Permission = "profile.edit" +) + +// catalog lists every role in descending order of privilege. +var catalog = []Role{Owner, Admin, Developer, Operator, Viewer, User} + +// grants is the permission matrix. ReadConsole is deliberately absent for User +// so that self-service members are routed to the profile screen instead of the +// management console. +var grants = map[Role]map[Permission]bool{ + Owner: { + ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true, + ManageContracts: true, ManageUsers: true, ManageTenants: true, EditProfile: true, + }, + Admin: { + ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true, + ManageContracts: true, ManageUsers: true, EditProfile: true, + }, + Developer: { + ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true, + ManageContracts: true, EditProfile: true, + }, + Operator: { + ReadConsole: true, ReadUsers: true, ManageKeys: true, ManageContracts: true, EditProfile: true, + }, + Viewer: { + ReadConsole: true, ReadUsers: true, EditProfile: true, + }, + User: { + EditProfile: true, + }, +} + +var ordered = []Permission{ + ReadConsole, ReadUsers, ManageApplications, ManageKeys, + ManageContracts, ManageUsers, ManageTenants, EditProfile, +} + +// Entry describes a role for the console and for API clients. +type Entry struct { + Value string `json:"value"` + Label string `json:"label"` + Permissions []string `json:"permissions"` +} + +var labels = map[Role]string{ + Owner: "Owner", + Admin: "Administrator", + Developer: "Developer", + Operator: "Operator", + Viewer: "Viewer", + User: "User", +} + +// All returns every role ordered from most to least privileged. +func All() []Role { + result := make([]Role, len(catalog)) + copy(result, catalog) + return result +} + +// Catalog returns every role with its label and permissions so that the console +// and API clients never hardcode the role list. +func Catalog() []Entry { + result := make([]Entry, 0, len(catalog)) + for _, item := range catalog { + result = append(result, Entry{Value: string(item), Label: labels[item], Permissions: item.Permissions()}) + } + return result +} + +// Parse converts a stored or submitted value into a Role. +func Parse(value string) (Role, bool) { + candidate := Role(value) + if _, known := grants[candidate]; !known { + return "", false + } + return candidate, true +} + +// Valid reports whether the value names a role. +func (r Role) Valid() bool { + _, known := grants[r] + return known +} + +// Allows reports whether the role grants the permission. +func (r Role) Allows(permission Permission) bool { + return grants[r][permission] +} + +// Permissions returns the role's permissions in a stable order. +func (r Role) Permissions() []string { + result := make([]string, 0, len(ordered)) + for _, permission := range ordered { + if r.Allows(permission) { + result = append(result, string(permission)) + } + } + return result +} + +// Grants returns the roles that grant a permission, for use as a SQL parameter +// so that role checks in queries stay aligned with this matrix. +func Grants(permission Permission) []string { + result := make([]string, 0, len(catalog)) + for _, item := range catalog { + if item.Allows(permission) { + result = append(result, string(item)) + } + } + return result +} + +// Grantable returns the roles a tenant administrator may hand to a new member. +// Ownership is not grantable during an invitation because it is established by +// creating or claiming a tenant. +func Grantable() []Role { + result := make([]Role, 0, len(catalog)-1) + for _, item := range catalog { + if item != Owner { + result = append(result, item) + } + } + return result +} + +// GrantableValues returns Grantable as strings for a request payload. +func GrantableValues() []string { + values := Grantable() + result := make([]string, len(values)) + for index, item := range values { + result[index] = string(item) + } + return result +} diff --git a/internal/role/role_test.go b/internal/role/role_test.go new file mode 100644 index 0000000..d22a107 --- /dev/null +++ b/internal/role/role_test.go @@ -0,0 +1,135 @@ +package role + +import ( + "slices" + "strings" + "testing" +) + +func TestParseRejectsUnknownRoles(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {value: "owner", want: true}, + {value: "admin", want: true}, + {value: "developer", want: true}, + {value: "operator", want: true}, + {value: "viewer", want: true}, + {value: "user", want: true}, + {value: "Owner", want: false}, + {value: "", want: false}, + {value: "superuser", want: false}, + {value: "owner; DROP TABLE memberships", want: false}, + } + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + _, ok := Parse(test.value) + if ok != test.want { + t.Fatalf("Parse(%q) ok = %v, want %v", test.value, ok, test.want) + } + }) + } +} + +func TestPermissionMatrix(t *testing.T) { + tests := []struct { + role Role + permission Permission + want bool + }{ + {role: Owner, permission: ManageTenants, want: true}, + {role: Owner, permission: ManageUsers, want: true}, + {role: Admin, permission: ManageTenants, want: false}, + {role: Admin, permission: ManageUsers, want: true}, + {role: Admin, permission: ManageApplications, want: true}, + {role: Developer, permission: ManageUsers, want: false}, + {role: Developer, permission: ManageApplications, want: true}, + {role: Developer, permission: ManageKeys, want: true}, + {role: Developer, permission: ManageContracts, want: true}, + {role: Operator, permission: ManageApplications, want: false}, + {role: Operator, permission: ManageKeys, want: true}, + {role: Operator, permission: ManageContracts, want: true}, + {role: Viewer, permission: ManageKeys, want: false}, + {role: Viewer, permission: ReadConsole, want: true}, + {role: User, permission: ReadConsole, want: false}, + {role: User, permission: ReadUsers, want: false}, + {role: User, permission: EditProfile, want: true}, + } + for _, test := range tests { + t.Run(string(test.role)+"/"+string(test.permission), func(t *testing.T) { + if got := test.role.Allows(test.permission); got != test.want { + t.Fatalf("Allows(%q) = %v, want %v", test.permission, got, test.want) + } + }) + } +} + +func TestEveryRoleCanEditItsOwnProfile(t *testing.T) { + for _, item := range All() { + if !item.Allows(EditProfile) { + t.Fatalf("role %q cannot edit its own profile", item) + } + } +} + +func TestGrantsMatchAllows(t *testing.T) { + for _, permission := range ordered { + granted := Grants(permission) + for _, item := range All() { + if slices.Contains(granted, string(item)) != item.Allows(permission) { + t.Fatalf("Grants(%q) and Allows disagree for role %q", permission, item) + } + } + } +} + +func TestGrantsListsRolesInPrivilegeOrder(t *testing.T) { + if got := Grants(ManageKeys); !slices.Equal(got, []string{"owner", "admin", "developer", "operator"}) { + t.Fatalf("Grants(ManageKeys) = %v", got) + } + if got := Grants(ManageUsers); !slices.Equal(got, []string{"owner", "admin"}) { + t.Fatalf("Grants(ManageUsers) = %v", got) + } + if got := Grants(ReadConsole); !slices.Contains(got, string(Owner)) || slices.Contains(got, string(User)) { + t.Fatalf("Grants(ReadConsole) = %v", got) + } +} + +func TestCatalogCoversEveryRoleWithALabel(t *testing.T) { + entries := Catalog() + if len(entries) != len(catalog) { + t.Fatalf("Catalog() length = %d, want %d", len(entries), len(catalog)) + } + for _, entry := range entries { + if entry.Label == "" { + t.Fatalf("role %q has no label", entry.Value) + } + parsed, ok := Parse(entry.Value) + if !ok { + t.Fatalf("catalog entry %q is not a known role", entry.Value) + } + if len(entry.Permissions) != len(parsed.Permissions()) { + t.Fatalf("role %q catalog permissions drifted from the matrix", entry.Value) + } + } +} + +func TestGrantableExcludesOwner(t *testing.T) { + for _, item := range Grantable() { + if item == Owner { + t.Fatal("owner must not be grantable through an invitation") + } + } + if len(GrantableValues()) != len(catalog)-1 { + t.Fatalf("GrantableValues() length = %d", len(GrantableValues())) + } +} + +func TestPermissionsUseDottedNames(t *testing.T) { + for _, permission := range All()[0].Permissions() { + if !strings.Contains(permission, ".") { + t.Fatalf("permission %q should be a dotted capability name", permission) + } + } +} diff --git a/internal/server/clientauth.go b/internal/server/clientauth.go index 8ea657b..f7e83cf 100644 --- a/internal/server/clientauth.go +++ b/internal/server/clientauth.go @@ -8,9 +8,35 @@ import ( "time" "github.com/danyel/go-loose/internal/key" + "github.com/danyel/go-loose/internal/role" "github.com/danyel/go-loose/internal/store" ) +// clientUserResponse is the identity payload handed to client applications. It +// carries the tenant role and its capabilities so that an application such as +// go-tell can gate features, plus an absolute avatar_url it can render directly. +type clientUserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + TenantID string `json:"tenant_id"` + TenantSlug string `json:"tenant_slug"` + ApplicationID string `json:"application_id"` + Application string `json:"application"` + Role string `json:"role"` + Permissions []string `json:"permissions"` +} + +func (s *Server) clientUser(user store.ClientUser) clientUserResponse { + return clientUserResponse{ + ID: user.ID, Email: user.Email, DisplayName: user.DisplayName, + AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID, + TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application, + Role: user.Role, Permissions: role.Role(user.Role).Permissions(), + } +} + func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state") @@ -99,7 +125,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ "access_token": token, "token_type": "Bearer", - "expires_in": int(time.Until(expiresAt).Seconds()), "user": user, + "expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user), }) } @@ -118,7 +144,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) { s.internalError(w, "load client user", err) return } - writeJSON(w, http.StatusOK, user) + writeJSON(w, http.StatusOK, s.clientUser(user)) } func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/profile.go b/internal/server/profile.go new file mode 100644 index 0000000..c1fa148 --- /dev/null +++ b/internal/server/profile.go @@ -0,0 +1,143 @@ +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.avatarURL(profile.AvatarKey), Memberships: profile.Memberships, + IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized, + CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt, + ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt, + } +} + +func (s *Server) avatarURL(key *string) string { + if key == nil || *key == "" { + return "" + } + return s.cfg.BaseURL + "/api/v1/avatars/" + *key +} diff --git a/internal/server/profile_test.go b/internal/server/profile_test.go new file mode 100644 index 0000000..9668698 --- /dev/null +++ b/internal/server/profile_test.go @@ -0,0 +1,194 @@ +package server + +import ( + "bytes" + "context" + "encoding/base64" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "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)) +} + +func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) { + server := profileTestServer() + if got := server.avatarURL(nil); got != "" { + t.Fatalf("avatarURL(nil) = %q, want empty", got) + } + empty := "" + if got := server.avatarURL(&empty); got != "" { + t.Fatalf("avatarURL(empty) = %q, want empty", got) + } + key := "k" + got := server.avatarURL(&key) + if want := "https://auth.example.test/api/v1/avatars/k"; got != want { + t.Fatalf("avatarURL() = %q, want %q", got, want) + } + parsed, err := url.Parse(got) + if err != nil || parsed.Path != "/api/v1/avatars/k" { + t.Fatalf("avatar url = %q, err = %v", got, err) + } +} + +func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) { + server := profileTestServer() + key := "avatar-key" + payload := server.clientUser(store.ClientUser{ + ID: "user-1", Email: "operator@example.test", DisplayName: "Operator", + TenantSlug: "nmbs", Application: "guess", Role: string(role.Operator), 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.ManageUsers)) { + t.Fatalf("permissions = %v, must not grant users.manage to an operator", payload.Permissions) + } + if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") { + t.Fatalf("avatar url = %q", payload.AvatarURL) + } +} + +func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) { + server := profileTestServer() + payload := server.clientUser(store.ClientUser{Role: string(role.User)}) + 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 TestInvitableRoleExcludesOwner(t *testing.T) { + if _, ok := invitableRole(string(role.Owner)); ok { + t.Fatal("an invitation must not be able to grant ownership") + } + for _, item := range role.Grantable() { + if _, ok := invitableRole(string(item)); !ok { + t.Fatalf("invitableRole(%q) = false", item) + } + } + for _, item := range []string{"", "superuser", "Owner", "user'", "owner"} { + if item == string(role.Owner) { + continue + } + if _, ok := invitableRole(item); ok { + t.Fatalf("invitableRole(%q) = true", item) + } + } +} + +func TestAssignableRoleAcceptsEveryKnownRole(t *testing.T) { + for _, item := range role.All() { + if got, ok := assignableRole(string(item)); !ok || got != string(item) { + t.Fatalf("assignableRole(%q) = %q, %v", item, got, ok) + } + } + for _, item := range []string{"", "superuser", "Owner", "admin,owner"} { + if _, ok := assignableRole(item); ok { + t.Fatalf("assignableRole(%q) = true", item) + } + } +} + +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/server.go b/internal/server/server.go index 8d4c3ee..7f9e775 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,7 @@ import ( "github.com/danyel/go-loose/internal/config" "github.com/danyel/go-loose/internal/contract" "github.com/danyel/go-loose/internal/key" + "github.com/danyel/go-loose/internal/role" "github.com/danyel/go-loose/internal/secretbox" "github.com/danyel/go-loose/internal/session" "github.com/danyel/go-loose/internal/store" @@ -107,6 +108,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /auth/logout", s.authLogout) mux.HandleFunc("POST /auth/password", s.passwordLogin) mux.HandleFunc("GET /assets/{name}", s.asset) + mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar) mux.HandleFunc("POST /api/v1/authorize", s.authorize) mux.HandleFunc("GET /connect/authorize", s.clientAuthorize) mux.HandleFunc("POST /connect/token", s.clientToken) @@ -117,6 +119,9 @@ 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 /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))) @@ -155,16 +160,26 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) { } func (s *Server) index(w http.ResponseWriter, r *http.Request) { - allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID) + claims := claimsFrom(r) + console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID) if err != nil { - s.internalError(w, "check management access", err) + s.internalError(w, "check console access", err) return } - if !allowed { - s.serveEmbedded(w, "waiting-room.html") + if console { + s.serveEmbedded(w, "index.html") return } - s.serveEmbedded(w, "index.html") + member, err := s.store.HasMembership(r.Context(), claims.UserID) + if err != nil { + s.internalError(w, "check membership", err) + return + } + if member { + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } + s.serveEmbedded(w, "waiting-room.html") } func (s *Server) docs(w http.ResponseWriter, _ *http.Request) { @@ -177,7 +192,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" { + if name != "style.css" && name != "app.js" && name != "profile.js" { http.NotFound(w, r) return } @@ -330,13 +345,13 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { claims := claimsFrom(r) - allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID) + allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID) if err != nil { - s.internalError(w, "check management access", err) + s.internalError(w, "check console access", err) return } if !allowed { - writeError(w, http.StatusForbidden, "waiting for administrator approval") + writeError(w, http.StatusForbidden, "this account has no console access") return } tenants, err := s.store.ListTenants(r.Context(), claims.UserID) @@ -374,9 +389,19 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { s.internalError(w, "check system administrator", err) return } + profile, err := s.store.Profile(r.Context(), claims.UserID) + if err != nil { + s.internalError(w, "load profile", err) + return + } + for index := range users { + users[index].AvatarURL = s.avatarURL(users[index].AvatarKey) + } writeJSON(w, http.StatusOK, map[string]any{ - "user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts, - "users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator, + "user": claims, "profile": s.profileResponse(profile), + "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts, + "users": users, "pending_users": pendingUsers, "roles": role.Catalog(), + "is_system_administrator": isSystemAdministrator, }) } @@ -703,7 +728,11 @@ func validSlug(value string) bool { } func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { - r.Body = http.MaxBytesReader(w, r.Body, 6<<20) + return decodeJSONLimit(w, r, 6<<20, target) +} + +func decodeJSONLimit(w http.ResponseWriter, r *http.Request, limit int64, target any) bool { + r.Body = http.MaxBytesReader(w, r.Body, limit) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { diff --git a/internal/server/tenantauth.go b/internal/server/tenantauth.go index 0161de6..a74e865 100644 --- a/internal/server/tenantauth.go +++ b/internal/server/tenantauth.go @@ -138,12 +138,14 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) { } tenantSlug := s.requestTenantSlug(r.Host) tenantLogin := tenantSlug != "" + loginPrompt := "admin@goloose:~" loginContext := "SYSTEM ADMINISTRATION" loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant." passwordButton := "LOGIN" if tenantLogin { loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION" loginMessage = "Sign in with your tenant database account." + loginPrompt = tenantSlug + "@goloose:~" passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug) } page := strings.NewReplacer( @@ -153,6 +155,7 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) { "{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton), "{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin), "{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()), + "{{LOGIN_PROMPT}}", loginPrompt, ).Replace(string(content)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(page)) diff --git a/internal/server/useradmin.go b/internal/server/useradmin.go index 7a83f3b..c1b0f64 100644 --- a/internal/server/useradmin.go +++ b/internal/server/useradmin.go @@ -8,8 +8,28 @@ import ( "github.com/danyel/go-loose/internal/key" "github.com/danyel/go-loose/internal/password" + "github.com/danyel/go-loose/internal/role" ) +// assignableRole validates a role that an administrator may hand to an existing +// member. Any known role qualifies, including owner, so that ownership can move +// between accounts deliberately. +func assignableRole(value string) (string, bool) { + parsed, ok := role.Parse(value) + return string(parsed), ok +} + +// invitableRole validates a role for a new invitation. Ownership is established +// by creating or claiming a tenant, so it is never granted here. +func invitableRole(value string) (string, bool) { + for _, item := range role.Grantable() { + if string(item) == value { + return value, true + } + } + return "", false +} + func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) { var request struct { RedirectURIs []string `json:"redirect_uris"` @@ -75,8 +95,9 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { if request.DisplayName == "" { request.DisplayName = request.Email } - if request.Role != "admin" && request.Role != "viewer" { - writeError(w, http.StatusBadRequest, "role must be admin or viewer") + invitedRole, ok := invitableRole(request.Role) + if !ok { + writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", ")) return } passwordHash, err := password.Hash(request.Password) @@ -84,11 +105,12 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash) + user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, invitedRole, passwordHash) if err != nil { s.handleStoreError(w, "invite user", err) return } + user.AvatarURL = s.avatarURL(user.AvatarKey) writeJSON(w, http.StatusCreated, user) } @@ -101,11 +123,12 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &request) { return } - if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" { + membershipRole, ok := assignableRole(request.Role) + if !ok { writeError(w, http.StatusBadRequest, "invalid role") return } - if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil { + if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil { s.handleStoreError(w, "update user access", err) return } @@ -121,13 +144,14 @@ func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &request) { return } - if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" { + membershipRole, ok := assignableRole(request.Role) + if !ok { writeError(w, http.StatusBadRequest, "invalid role") return } if err := s.store.ApprovePendingUser( r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), - request.Role, request.ApplicationIDs, + membershipRole, request.ApplicationIDs, ); err != nil { s.handleStoreError(w, "approve pending user", err) return diff --git a/internal/store/clientauth.go b/internal/store/clientauth.go index 482c585..eaf513c 100644 --- a/internal/store/clientauth.go +++ b/internal/store/clientauth.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "time" + + "github.com/danyel/go-loose/internal/role" ) type ManagedUser struct { @@ -14,8 +16,13 @@ type ManagedUser struct { Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` + Permissions []string `json:"permissions"` Status string `json:"status"` ApplicationIDs []string `json:"application_ids"` + // AvatarKey is the stored locator. The server turns it into the absolute + // AvatarURL returned to clients. + AvatarKey *string `json:"-"` + AvatarURL string `json:"avatar_url"` } type ClientApplication struct { @@ -36,12 +43,21 @@ 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 []string `json:"permissions"` + // AvatarKey is the stored locator; the server expands it into AvatarURL. + AvatarKey *string `json:"-"` + AvatarURL string `json:"avatar_url"` } func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) { rows, err := s.db.QueryContext(ctx, ` SELECT u.id, m.tenant_id, u.email, u.display_name, m.role, CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END, + u.avatar_key, COALESCE(( SELECT json_agg(ua.application_id ORDER BY ua.application_id) FROM user_application_access ua @@ -53,9 +69,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ 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') + AND own.role = ANY($2) ) - ORDER BY u.email, m.tenant_id`, administratorID) + ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers)) if err != nil { return nil, err } @@ -64,18 +80,20 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ for rows.Next() { var user ManagedUser var applicationIDs []byte - if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil { + if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, + &user.Status, &user.AvatarKey, &applicationIDs); err != nil { return nil, err } if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil { return nil, err } + user.Permissions = role.Role(user.Role).Permissions() result = append(result, user) } return result, rows.Err() } -func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) { +func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, membershipRole, passwordHash string) (ManagedUser, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return ManagedUser{}, err @@ -85,8 +103,8 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email 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 { + WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3) + )`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil { return ManagedUser{}, err } if !permitted { @@ -109,17 +127,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email if _, err := tx.ExecContext(ctx, ` INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, - tenantID, user.ID, role); err != nil { + tenantID, user.ID, membershipRole); err != nil { return ManagedUser{}, err } if err := tx.Commit(); err != nil { return ManagedUser{}, err } - user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{} + user.TenantID, user.Role, user.ApplicationIDs = tenantID, membershipRole, []string{} + user.Permissions = role.Role(membershipRole).Permissions() return user, nil } -func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error { +func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err @@ -130,8 +149,8 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us 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) + AND administrator.user_id = $3 AND administrator.role = ANY($5)`, + tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers)) if err != nil { return err } @@ -165,12 +184,12 @@ func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicatio var encoded []byte err := s.db.QueryRowContext(ctx, ` UPDATE applications a - SET redirect_uris = $3, client_secret_hash = $4, updated_at = now() + SET redirect_uris = $4, client_secret_hash = $5, 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') + AND m.role = ANY($3) RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`, - applicationID, administratorID, redirectURIs, secretHash). + applicationID, administratorID, role.Grants(role.ManageApplications), redirectURIs, secretHash). Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady) if errors.Is(err, sql.ErrNoRows) { return Application{}, ErrNotFound @@ -246,14 +265,20 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec var user ClientUser err = tx.QueryRowContext(ctx, ` UPDATE client_authorization_codes c SET used_at = now() - FROM applications a, tenants t, users u + FROM applications a + JOIN tenants t ON t.id = a.tenant_id + JOIN user_application_access ua ON ua.application_id = a.id + JOIN users u ON u.id = ua.user_id + LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now() AND c.redirect_uri = $2 AND a.id = c.application_id + AND ua.user_id = c.user_id AND ua.application_id = a.id AND a.client_id = $3 AND a.client_secret_hash = $4 - AND t.id = a.tenant_id AND u.id = c.user_id - RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`, - codeHash, redirectURI, clientID, secretHash). - Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application) + RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug, + COALESCE(m.role, $5), u.avatar_key`, + codeHash, redirectURI, clientID, secretHash, role.User). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, + &user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey) if errors.Is(err, sql.ErrNoRows) { return ClientUser{}, time.Time{}, ErrNotFound } @@ -273,12 +298,18 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client var user ClientUser err := s.db.QueryRowContext(ctx, ` UPDATE client_user_sessions s SET last_used_at = now() - FROM applications a, tenants t, users u, user_application_access ua + FROM applications a + JOIN tenants t ON t.id = a.tenant_id + JOIN user_application_access ua ON ua.application_id = a.id + JOIN users u ON u.id = ua.user_id + LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() - AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id - AND ua.application_id = a.id AND ua.user_id = u.id - RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`, - tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application) + AND a.id = s.application_id AND ua.user_id = s.user_id + RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug, + COALESCE(m.role, $2), u.avatar_key`, + tokenHash, role.User). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, + &user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey) if errors.Is(err, sql.ErrNoRows) { return ClientUser{}, ErrNotFound } diff --git a/internal/store/installation.go b/internal/store/installation.go index 75f2ff9..10b9917 100644 --- a/internal/store/installation.go +++ b/internal/store/installation.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "errors" + + "github.com/danyel/go-loose/internal/role" ) type Installation struct { @@ -78,8 +80,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser return err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin') - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil { + INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, + tenantID, userID, role.Admin); err != nil { return err } if _, err := tx.ExecContext(ctx, ` @@ -113,7 +116,7 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string } if _, err := tx.ExecContext(ctx, ` INSERT INTO memberships(tenant_id, user_id, role) - SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil { + SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil { return false, err } if _, err := tx.ExecContext(ctx, ` @@ -124,11 +127,24 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string return true, tx.Commit() } -func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) { +// HasConsoleAccess reports whether the user may read the management console. +// System administrators always qualify; otherwise the membership role must +// grant console.read, which excludes self-service User members. +func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) { var allowed bool err := s.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1) - OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed) + OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1 AND role = ANY($2))`, + userID, role.Grants(role.ReadConsole)).Scan(&allowed) + return allowed, err +} + +// HasMembership reports whether the user belongs to any tenant. A member without +// console access is a self-service user rather than a waiting-room visitor. +func (s *Store) HasMembership(ctx context.Context, userID string) (bool, error) { + var allowed bool + err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID). + Scan(&allowed) return allowed, err } diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go new file mode 100644 index 0000000..692ea66 --- /dev/null +++ b/internal/store/integration_test.go @@ -0,0 +1,510 @@ +package store_test + +import ( + "context" + "database/sql" + "errors" + "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") + } + 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 +} + +// 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 role. +func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string { + t.Helper() + member, err := s.InviteUser(ctx, owner, tenantID, email, email, membershipRole, "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 []role.Role{role.Developer, role.Operator, role.User} { + addMember(t, s, ctx, owner, tenant.ID, string(item)+"@example.test", string(item)) + } + if _, err := s.ListTenants(ctx, owner); err != nil { + t.Fatalf("list tenants: %v", err) + } +} + +func TestMembershipRoleConstraintRejectsUnknownRole(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", string(role.Viewer)) + if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "superuser", nil); err == nil { + t.Fatal("the database must reject a role outside the permission matrix") + } +} + +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 role.Role + canCreateApp bool + canIssueKey bool + canSaveContract bool + canListManaged bool + canConfigureClien bool + }{ + {role: role.Owner, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, + {role: role.Admin, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, + {role: role.Developer, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: true}, + {role: role.Operator, canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: false}, + {role: role.Viewer, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false}, + {role: role.User, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false}, + } + for _, test := range tests { + t.Run(string(test.role), func(t *testing.T) { + member := addMember(t, s, ctx, owner, tenant.ID, string(test.role)+"@example.test", string(test.role)) + slug := strings.ReplaceAll(string(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", string(role.User)) + operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", string(role.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", string(role.Developer)) + + tenants, err := s.ListTenants(ctx, developer) + if err != nil { + t.Fatalf("list tenants: %v", err) + } + if len(tenants) != 1 || tenants[0].Role != string(role.Developer) { + t.Fatalf("tenants = %+v", tenants) + } + if !contains(tenants[0].Permissions, string(role.ManageApplications)) { + t.Fatalf("developer permissions = %v", tenants[0].Permissions) + } + if contains(tenants[0].Permissions, string(role.ManageUsers)) { + t.Fatalf("developer permissions = %v, must not manage users", 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", string(role.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 != string(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", string(role.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", string(role.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 != string(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 != string(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 != string(role.User) { + t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, 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 +} diff --git a/internal/store/profile.go b/internal/store/profile.go new file mode 100644 index 0000000..4296f4e --- /dev/null +++ b/internal/store/profile.go @@ -0,0 +1,150 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/danyel/go-loose/internal/role" +) + +// 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"` + 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, m.role + FROM memberships m + JOIN tenants t ON t.id = m.tenant_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 + if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role); err != nil { + return Profile{}, err + } + item.Permissions = role.Role(item.Role).Permissions() + 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/store.go b/internal/store/store.go index b839ee5..24f7f80 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -10,6 +10,7 @@ import ( "time" "github.com/danyel/go-loose/internal/contract" + "github.com/danyel/go-loose/internal/role" ) var ErrNotFound = errors.New("not found") @@ -29,6 +30,9 @@ type Tenant struct { Slug string `json:"slug"` Name string `json:"name"` Role string `json:"role"` + // Permissions is derived from Role so that the console and API clients read + // capabilities instead of re-implementing the role matrix. + Permissions []string `json:"permissions"` } type Application struct { @@ -86,8 +90,15 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri return User{}, err } defer tx.Rollback() + // The identity provider owns the display name until the user customizes it + // in their profile. The row must still match on every login, otherwise the + // insert below would collide with the unique email index. err = tx.QueryRowContext(ctx, ` - UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now() + UPDATE users + SET subject = $1, + email = lower($2), + display_name = CASE WHEN name_customized THEN display_name ELSE $3 END, + last_login_at = now() WHERE lower(email) = lower($2) RETURNING id, email, display_name`, subject, email, displayName). Scan(&user.ID, &user.Email, &user.DisplayName) @@ -120,10 +131,10 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin } if _, err = tx.ExecContext(ctx, ` INSERT INTO memberships(tenant_id, user_id, role) - SELECT $1, $2, 'owner' + SELECT $1, $2, $3 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 { + ON CONFLICT DO NOTHING`, tenantID, userID, role.Owner); err != nil { return err } if _, err = tx.ExecContext(ctx, ` @@ -161,6 +172,7 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil { return nil, err } + item.Permissions = role.Role(item.Role).Permissions() result = append(result, item) } return result, rows.Err() @@ -181,7 +193,8 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te 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" + tenant.Role = string(role.Owner) + tenant.Permissions = role.Owner.Permissions() if err := tx.Commit(); err != nil { return Tenant{}, err } @@ -225,11 +238,13 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts) SELECT $1, $2, $3, $4, $5 WHERE EXISTS ( - SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin') + SELECT 1 FROM memberships + WHERE tenant_id = $1 AND user_id = $6 AND role = ANY($7) ) RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts), client_id, to_json(redirect_uris), false`, - app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID) + app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID, + role.Grants(role.ManageApplications)) var allowedHosts, redirectURIs []byte err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady) if errors.Is(err, sql.ErrNoRows) { @@ -273,9 +288,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6 FROM applications a JOIN memberships m ON m.tenant_id = a.tenant_id - WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin') + WHERE a.id = $1 AND m.user_id = $6 AND m.role = ANY($7) RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`, - applicationID, name, prefix, hash, expiresAt, userID). + applicationID, name, prefix, hash, expiresAt, userID, role.Grants(role.ManageKeys)). Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return APIKey{}, ErrNotFound @@ -287,8 +302,8 @@ func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error { result, err := s.db.ExecContext(ctx, ` UPDATE api_keys k SET status = 'revoked', revoked_at = now() FROM memberships m - WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`, - keyID, userID) + WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`, + keyID, userID, role.Grants(role.ManageKeys)) if err != nil { return err } @@ -349,9 +364,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string, SELECT a.id, $2, $3, $4, $5 FROM applications a JOIN memberships m ON m.tenant_id = a.tenant_id - WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin') + WHERE a.id = $1 AND m.user_id = $5 AND m.role = ANY($6) RETURNING id, application_id, version, source_url, created_at`, - applicationID, document.Version, sourceURL, document.JSON, userID). + applicationID, document.Version, sourceURL, document.JSON, userID, + role.Grants(role.ManageContracts)). Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return Contract{}, ErrNotFound diff --git a/web/app.js b/web/app.js index 191cd90..6f5535a 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,15 @@ -const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''}; +const state = { + tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], + roles: [], profile: null, selectedTenant: '' +}; +// Capability names mirror internal/role. The server sends the grants for the +// selected tenant so the console never re-implements the matrix. +const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission)); const $ = selector => document.querySelector(selector); + +const roleOptions = roles => roles + .map(item => ``) + .join(''); const $$ = selector => [...document.querySelectorAll(selector)]; const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', @@ -38,14 +48,38 @@ async function load() { const data = await api('/api/v1/dashboard'); Object.assign(state, data); if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || ''; - $('#identity').textContent = data.user.display_name || data.user.email; + renderIdentity(); + renderRoleOptions(); render(); } +function renderIdentity() { + const profile = state.profile || {}; + $('#identity-name').textContent = profile.display_name || profile.email || ''; + const image = $('#identity-avatar'); + if (profile.avatar_url) { + image.src = profile.avatar_url; + image.hidden = false + } else { + image.hidden = true; + image.removeAttribute('src') + } +} + +function renderRoleOptions() { + // Inviting an owner is rejected by the server, so leave it out of the list. + const invitable = state.roles.filter(item => item.value !== 'owner'); + $('#invite-role').innerHTML = roleOptions(invitable); + $('#access-role').innerHTML = roleOptions(state.roles); +} + function render() { const tenantID = state.selectedTenant; const selectedTenant = state.tenants.find(item => item.id === tenantID); - const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role); + const canManageApplications = grants(selectedTenant, 'applications.manage'); + const canManageKeys = grants(selectedTenant, 'keys.manage'); + const canManageContracts = grants(selectedTenant, 'contracts.manage'); + const canManageUsers = grants(selectedTenant, 'users.manage'); const tenantApps = state.applications.filter(item => item.tenant_id === tenantID); const tenantAppIDs = new Set(tenantApps.map(item => item.id)); const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id)); @@ -60,12 +94,15 @@ function render() { $('#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)}

${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'); + $('#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)}
${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'); $('#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 => `${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); + $('#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(item.role)}${escapeHTML(item.status)}${item.application_ids.length}${canManageUsers ? `` : ''}`).join('') + pending || rowEmpty(6, 'No users'); + $$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications); + $$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys); + $$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts); + $$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers); $$('.tenant-select').forEach(select => { select.innerHTML = state.tenants.map(item => ``).join(''); select.value = tenantID; @@ -74,6 +111,10 @@ function render() { } const empty = text => `

${text}

`; +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) { diff --git a/web/index.html b/web/index.html index cf55193..8dc2a9b 100644 --- a/web/index.html +++ b/web/index.html @@ -18,7 +18,7 @@ - +
@@ -58,15 +58,15 @@
-

SERVICE CATALOG

Applications

+

SERVICE CATALOG

Applications

-

CREDENTIALS

API keys

+

CREDENTIALS

API keys

NameApplicationPrefixStatusLast used
-

DISCOVERY

OpenAPI contracts

+

DISCOVERY

OpenAPI contracts

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

CLIENT IDENTITIES

Users & access

+

CLIENT IDENTITIES

Users & access

UserTenantRoleStatusApplications
+

Roles come from the shared permission matrix. Open your own profile to change your name and picture.

@@ -86,8 +87,8 @@

Issue API key

Import OpenAPI contract

Configure client login

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

-

Invite tenant user

-

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

{{LOGIN_CONTEXT}}

-

goloose@access-plane:~$ initialize secure session

-

{{LOGIN_MESSAGE}}

+

{{LOGIN_CONTEXT}}

+

{{LOGIN_PROMPT}}$ initialize secure session

+

{{LOGIN_MESSAGE}}

-

goloose@access-plane:~$ █

+

{{LOGIN_PROMPT}}$ █

diff --git a/web/openapi.json b/web/openapi.json index 541d357..eec0dc9 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -38,6 +38,50 @@ "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", "enum": ["owner", "admin", "developer", "operator", "viewer", "user"]}, + "permissions": {"type": "array", "items": {"type": "string"}} + } + }, + "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"} + } + }, + "RoleCatalogEntry": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "label": {"type": "string"}, + "permissions": {"type": "array", "items": {"type": "string"}} + } } } }, @@ -93,10 +137,54 @@ "Dashboard Management" ], "summary": "Get management console data", + "description": "Requires a role that grants console.read. Each tenant and user entry carries the capability names its role grants.", "security": [{"session": []}], "responses": {"200": {"description": "Dashboard data"}} } }, + "/profile": { + "get": { + "tags": ["Profile"], + "summary": "Open the self-service profile screen", + "description": "Available to every authenticated management session, including self-service User members.", + "security": [{"session": []}], + "responses": {"200": {"description": "Profile screen"}, "303": {"description": "Redirect to login"}} + } + }, + "/api/v1/profile": { + "get": { + "tags": ["Profile"], + "summary": "Read the caller's own profile and tenant roles", + "security": [{"session": []}], + "responses": { + "200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}}, + "401": {"description": "Authentication required"} + } + }, + "put": { + "tags": ["Profile"], + "summary": "Update the caller's display name or profile picture", + "security": [{"session": []}], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdate"}}}}, + "responses": { + "200": {"description": "Updated profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}}, + "400": {"description": "Invalid display name or unsupported picture"} + } + } + }, + "/api/v1/avatars/{key}": { + "get": { + "tags": ["Profile"], + "summary": "Fetch a profile picture", + "description": "Public on purpose so other applications can embed the picture with a plain image tag. The key is 256 bits of randomness and exposes nothing about the user.", + "parameters": [{"in": "path", "name": "key", "required": true, "schema": {"type": "string"}}], + "responses": { + "200": {"description": "Image bytes", "content": {"image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/png": {"schema": {"type": "string", "format": "binary"}}, "image/gif": {"schema": {"type": "string", "format": "binary"}}}}, + "304": {"description": "Not modified"}, + "404": {"description": "No picture for that key"} + } + } + }, "/api/v1/tenants": { "post": { "tags": [ @@ -169,6 +257,7 @@ "User Action" ], "summary": "Exchange a one-time authorization code", + "description": "The nested user object carries role, permissions, and avatar_url for the tenant owning the application.", "security": [{"clientBasic": []}], "requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}} @@ -179,7 +268,8 @@ "tags": [ "User Action" ], - "summary": "Validate a client user session", + "summary": "Validate a client user session and read the caller's role and picture", + "description": "Returns id, email, display_name, avatar_url, tenant and application identity, role, and permissions.", "security": [{"userBearer": []}], "responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}} } @@ -210,7 +300,7 @@ "tags": [ "User Management" ], - "summary": "Invite a tenant user", + "summary": "Invite a tenant user with an admin, developer, operator, viewer, or user role", "security": [{"session": []}], "responses": {"201": {"description": "User invited"}} } @@ -221,11 +311,20 @@ "User Management" ], "summary": "Set tenant role and application access", + "description": "Any known role is accepted, including owner.", "security": [{"session": []}], "parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}], "responses": {"204": {"description": "Access updated"}} } }, + "/api/v1/roles": { + "get": { + "tags": ["User Management"], + "summary": "List tenant roles with their capabilities", + "security": [{"session": []}], + "responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/RoleCatalogEntry"}}}}}} + } + }, "/api/v1/users/{id}/approve": { "post": { "tags": ["User Management"], diff --git a/web/profile.html b/web/profile.html new file mode 100644 index 0000000..5e6ed2b --- /dev/null +++ b/web/profile.html @@ -0,0 +1,79 @@ + + + + + + + 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 new file mode 100644 index 0000000..f90accf --- /dev/null +++ b/web/profile.js @@ -0,0 +1,166 @@ +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) + }) +} + +const theme = $('#theme'); +theme.value = localStorage.getItem('gl-theme') || 'tokyo'; +document.documentElement.dataset.theme = theme.value; +document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark'; +theme.addEventListener('change', () => { + document.documentElement.dataset.theme = theme.value; + localStorage.setItem('gl-theme', theme.value) +}); +$('#mode').addEventListener('click', () => { + const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.mode = next; + localStorage.setItem('gl-mode', next) +}); + +load().catch(error => notice(error.message, true)); \ No newline at end of file diff --git a/web/style.css b/web/style.css index 17d4cad..f1d6351 100644 --- a/web/style.css +++ b/web/style.css @@ -16,3 +16,4 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border .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} From 3fdc8cb4895a6f2f30e161afeefbd9d307918132 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 4 Oct 2026 16:57:30 +0200 Subject: [PATCH 06/10] roles --- .dockerignore | 1 + Makefile | 7 +- docs/roles.md | 61 ++- .../migrations/0006_database_roles.sql | 106 ++++ internal/role/role.go | 237 ++++----- internal/role/role_test.go | 198 ++++---- internal/server/clientauth.go | 3 +- internal/server/origin_test.go | 97 ++++ internal/server/profile_test.go | 49 +- internal/server/roles.go | 151 ++++++ internal/server/roles_test.go | 177 +++++++ internal/server/server.go | 75 ++- internal/server/useradmin.go | 47 +- internal/store/clientauth.go | 168 +++++-- internal/store/installation.go | 38 +- internal/store/integration_test.go | 75 +-- internal/store/profile.go | 19 +- internal/store/roles.go | 455 ++++++++++++++++++ internal/store/roles_integration_test.go | 242 ++++++++++ internal/store/store.go | 74 +-- web/app.js | 65 ++- web/index.html | 7 +- web/openapi.json | 93 +++- web/profile.html | 1 + web/roles.html | 90 ++++ web/roles.js | 197 ++++++++ 26 files changed, 2245 insertions(+), 488 deletions(-) create mode 100644 internal/database/migrations/0006_database_roles.sql create mode 100644 internal/server/roles.go create mode 100644 internal/server/roles_test.go create mode 100644 internal/store/roles.go create mode 100644 internal/store/roles_integration_test.go create mode 100644 web/roles.html create mode 100644 web/roles.js diff --git a/.dockerignore b/.dockerignore index 93e14b7..cc30164 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,5 @@ bin coverage.out google_secrets.json +data diff --git a/Makefile b/Makefile index a0ace0d..7b36763 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHELL := /bin/sh GO_LOOSE_ADDR ?= :8081 +GO_LOOSE_PORT ?= 8081 GO_LOOSE_BASE_URL ?= http://localhost:$(GO_LOOSE_PORT) GO_LOOSE_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose?sslmode=disable GO_LOOSE_SESSION_SECRET ?= local-development-secret-change-me-now @@ -29,8 +30,12 @@ integration-test: run: go run ./cmd/go-loose +# The variables above are exported for `make run`, but compose reads +# GO_LOOSE_BASE_URL too and would inherit the local http://localhost:8081 value, +# which then overrides the https:// defaults in docker-compose.yaml. Clearing it +# for the compose targets lets compose apply its own defaults. compose-up: - docker compose up --build + GO_LOOSE_BASE_URL= docker compose up --build compose-down: docker compose down diff --git a/docs/roles.md b/docs/roles.md index 366f9b1..3016b7a 100644 --- a/docs/roles.md +++ b/docs/roles.md @@ -1,36 +1,71 @@ # Roles and capabilities -Every tenant role maps to a fixed set of capabilities in `internal/role`. The console reads the capability list from the server instead of hardcoding role names, and store queries pass `role.Grants(...)` into SQL so authorization and rendering cannot drift apart. +A role is a named bundle of permissions. Roles are rows: six built-in ones seeded by migration `0006_database_roles.sql`, plus whatever each tenant defines for itself. `internal/role` no longer holds a matrix — it holds the closed vocabulary of permissions a role may be granted, and the database resolves what a membership actually grants. -## Capability matrix +``` +roles one row per role. tenant_id NULL means built-in. +role_permissions (role_id, permission) — what the role grants. +memberships.role_id → roles.id, NOT NULL, ON DELETE RESTRICT +``` -| Capability | owner | admin | developer | operator | viewer | user | +## Built-in capability matrix + +This is what the migration seeds. It is data, not code: change it with a new migration, not by editing Go. + +| Permission | owner | admin | developer | operator | viewer | user | | --- | --- | --- | --- | --- | --- | --- | -| `console.read` | yes | yes | yes | yes | yes | no | +| `tenants.manage` | yes | no | no | no | no | no | +| `roles.manage` | yes | yes | no | no | no | no | +| `users.manage` | yes | yes | yes | yes | no | 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 | no | no | no | no | +| `users.read` | yes | yes | yes | yes | yes | no | +| `console.read` | yes | yes | yes | yes | yes | no | | `profile.edit` | yes | yes | yes | yes | yes | yes | Intended use: -- **owner** — full control of the tenant, including role assignment. -- **admin** — manages applications, keys, contracts, and members. -- **developer** — ships service contracts and API keys, but cannot change membership. -- **operator** — runs existing applications: rotates keys and imports contracts. +- **owner** — full control of the tenant, including defining roles and handing them out. +- **admin** — manages applications, keys, contracts, members, and roles. +- **developer** — ships applications, keys, and contracts, and maintains the tenant's members. +- **operator** — runs existing applications and maintains the tenant's members. - **viewer** — read-only console access. - **user** — self-service only. No console access; the session lands on `/profile`. +`developer` and `operator` hold `users.manage` so that anyone who runs the console can maintain their own tenant's members. `roles.manage` deliberately stays narrow: defining roles is what defines privilege, so it is not something a member administrator can grant themselves. + `user` members without any tenant membership still see the waiting room. Those with a membership are redirected from `/` to `/profile`, because the dashboard requires `console.read`. +## Tenant-defined roles + +`POST /api/v1/roles` creates a role for one tenant. It is visible only in that tenant, and the `/roles` screen lists it beside the built-ins with its permission chips. + +Three rules keep this from becoming a privilege-escalation hole: + +1. **Roles are scoped.** `roles.tenant_id` is set, and every query that resolves a role filters on the caller's own tenant, so a role id cannot be probed across tenants. +2. **You cannot mint what you do not hold.** Creating or editing a role requires `roles.manage` *and* requires every permission in the role to be one the caller already has. Assigning a role enforces the same subset rule. +3. **A role in use cannot be deleted.** `memberships.role_id` is `ON DELETE RESTRICT`, and `DELETE /api/v1/roles/{id}` reports 409 until the members are moved. Built-in roles are immutable and report 404. + +Built-in roles are read only: the store refuses to edit or delete a row with `is_system` set. + ## Where the checks live -Store methods enforce capabilities inside SQL (`role = ANY($n)` with a text array) or inside the same transaction as the write. The server additionally filters and labels UI controls with `role.Allows`. Both layers read the same matrix, so adding a capability or a role is a one-line change in `internal/role/role.go`. +Tenant-scoped statements resolve the grant through `role_permissions` using one shared predicate, `permissionPredicate` in `internal/store/roles.go`: -`role.Role` values are also validated by a database check constraint on `memberships.role`, so an unknown role cannot be stored even if a caller bypasses the Go layer. +```sql +EXISTS ( + SELECT 1 + FROM memberships scoped + JOIN role_permissions granted ON granted.role_id = scoped.role_id + WHERE scoped.tenant_id = $1 AND scoped.user_id = $2 + AND granted.permission = $3 +) +``` -New roles must be added to the check constraint in a new migration as well as to the matrix; existing deployments keep the constraint in `internal/database/migrations/0005_roles_and_profile.sql` for historical reasons. +Because the check asks for a permission rather than a role name, a tenant's own role works everywhere a built-in one does without any Go change. The server never decides authorization: `handleStoreError` maps `store.ErrNotFound` to a 404 so a denied write and a missing row are indistinguishable. + +`role.Permission` names are also constrained by a check constraint on `role_permissions.permission`, so an unknown capability cannot be stored even if a caller bypasses the Go layer. **Adding a permission therefore needs three edits**: the constant in `internal/role/role.go`, the check constraint and seed in a new migration, and the expected grants in `internal/store/roles_integration_test.go`. ## Display names and profile pictures @@ -50,4 +85,4 @@ Accepted formats are JPEG, PNG, and GIF up to 2 MiB and 4096 by 4096 pixels. Rep `/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application. `client.User.Can` exposes the same checks to Go code, so a client can adapt its UI without duplicating the matrix. -A user with no membership in the application's tenant still authenticates, but the role falls back to `user` with only `profile.edit`. \ No newline at end of file +A user with no membership in the application's tenant still authenticates, but the role falls back to the built-in `user` role with only `profile.edit`. \ No newline at end of file diff --git a/internal/database/migrations/0006_database_roles.sql b/internal/database/migrations/0006_database_roles.sql new file mode 100644 index 0000000..d8bd84d --- /dev/null +++ b/internal/database/migrations/0006_database_roles.sql @@ -0,0 +1,106 @@ +-- +goose Up +-- Roles become rows so a tenant can define its own. A NULL tenant_id marks a +-- built-in role: every tenant sees it and nobody may edit or delete it. +CREATE TABLE roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 100), + description text NOT NULL DEFAULT '', + is_system boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + -- Custom roles belong to exactly one tenant; built-in roles to none. + CONSTRAINT roles_scope CHECK ( + (is_system AND tenant_id IS NULL) OR (NOT is_system AND tenant_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX roles_system_slug_unique ON roles (slug) WHERE tenant_id IS NULL; +CREATE UNIQUE INDEX roles_tenant_slug_unique ON roles (tenant_id, slug) WHERE tenant_id IS NOT NULL; + +-- The permission vocabulary is closed, so keep this list in step with the +-- constants in internal/role. +CREATE TABLE role_permissions ( + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission text NOT NULL CHECK (permission IN ( + 'console.read', 'users.read', 'applications.manage', 'keys.manage', + 'contracts.manage', 'users.manage', 'roles.manage', 'tenants.manage', + 'profile.edit')), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (role_id, permission) +); + +INSERT INTO roles (slug, name, description, is_system) VALUES + ('owner', 'Owner', 'Full control of the tenant, including roles, members, and tenant settings.', true), + ('admin', 'Administrator', 'Manages applications, credentials, contracts, members, and roles.', true), + ('developer', 'Developer', 'Manages applications, credentials, contracts, and members.', true), + ('operator', 'Operator', 'Issues and revokes credentials, imports contracts, and manages members.', true), + ('viewer', 'Viewer', 'Reads the console without changing anything.', true), + ('user', 'User', 'Self-service member with no console access.', true); + +-- Seed what used to be the Go matrix. developer and operator join owner and +-- admin in holding users.manage so that any tenant member who runs the console +-- can maintain their own tenant's members. roles.manage stays with owner and +-- admin: minting roles is what defines privilege in the first place. +INSERT INTO role_permissions (role_id, permission) +SELECT r.id, granted.permission +FROM (VALUES + ('owner', ARRAY[ + 'console.read', 'users.read', 'applications.manage', 'keys.manage', + 'contracts.manage', 'users.manage', 'roles.manage', 'tenants.manage', + 'profile.edit']), + ('admin', ARRAY[ + 'console.read', 'users.read', 'applications.manage', 'keys.manage', + 'contracts.manage', 'users.manage', 'roles.manage', 'profile.edit']), + ('developer', ARRAY[ + 'console.read', 'users.read', 'applications.manage', 'keys.manage', + 'contracts.manage', 'users.manage', 'profile.edit']), + ('operator', ARRAY[ + 'console.read', 'users.read', 'keys.manage', 'contracts.manage', + 'users.manage', 'profile.edit']), + ('viewer', ARRAY['console.read', 'users.read', 'profile.edit']), + ('user', ARRAY['profile.edit']) +) AS seed(slug, permissions) +JOIN roles r ON r.slug = seed.slug AND r.tenant_id IS NULL +CROSS JOIN LATERAL unnest(seed.permissions) AS granted(permission); + +-- Point every membership at a role row. RESTRICT rather than CASCADE so that a +-- role still held by somebody cannot be deleted out from under them. +ALTER TABLE memberships ADD COLUMN role_id uuid REFERENCES roles(id) ON DELETE RESTRICT; + +UPDATE memberships m SET role_id = r.id +FROM roles r +WHERE r.tenant_id IS NULL AND r.slug = m.role; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM memberships WHERE role_id IS NULL) THEN + RAISE EXCEPTION 'cannot migrate memberships: a membership names a role that was never seeded'; + END IF; +END +$$; + +ALTER TABLE memberships ALTER COLUMN role_id SET NOT NULL; +ALTER TABLE memberships DROP CONSTRAINT memberships_role_check; +ALTER TABLE memberships DROP COLUMN role; + +-- +goose Down +ALTER TABLE memberships ADD COLUMN role text; + +UPDATE memberships m SET role = r.slug +FROM roles r +WHERE r.id = m.role_id AND r.is_system; + +-- Custom roles have no equivalent in the old fixed vocabulary. +UPDATE memberships SET role = 'viewer' WHERE role IS NULL; + +ALTER TABLE memberships DROP CONSTRAINT memberships_role_check; +ALTER TABLE memberships + ADD CONSTRAINT memberships_role_check + CHECK (role IN ('owner', 'admin', 'developer', 'operator', 'viewer', 'user')); +ALTER TABLE memberships ALTER COLUMN role SET NOT NULL; +ALTER TABLE memberships DROP COLUMN role_id; + +DROP TABLE role_permissions; +DROP TABLE roles; diff --git a/internal/role/role.go b/internal/role/role.go index c4f3bda..bbaa361 100644 --- a/internal/role/role.go +++ b/internal/role/role.go @@ -1,175 +1,132 @@ -// Package role defines the tenant membership roles and the single permission -// matrix that authorizes console and management behavior. +// Package role defines the permission vocabulary that authorizes console and +// management behavior. // -// Roles are stored in memberships.role. Authorization decisions must derive -// from Grants or Allows so that SQL role checks and Go checks cannot drift -// apart. +// Roles are rows in the roles table and their permissions live in +// role_permissions, so a tenant can define its own and authorization is +// resolved by the database rather than from a matrix held here. What belongs in +// this package is the closed set of permissions a role may be granted, plus the +// labels the console renders. +// +// Keep the permission list in step with the role_permissions_permission_check +// constraint in migration 0006: a name that is not in both places cannot be +// stored. package role -// Role is a tenant membership role. -type Role string - -const ( - // Owner has full control of a tenant, including tenant-level settings. - Owner Role = "owner" - // Admin manages applications, credentials, contracts, and members. - Admin Role = "admin" - // Developer manages applications, credentials, and contracts. - Developer Role = "developer" - // Operator issues and revokes credentials and imports contracts. - Operator Role = "operator" - // Viewer reads the console without changing anything. - Viewer Role = "viewer" - // User is a self-service member. It carries no console access and exists so - // that end users of client applications still hold a tenant role that - // applications can read through /connect/userinfo. - User Role = "user" -) - // Permission is a capability granted by a role. type Permission string const ( - ReadConsole Permission = "console.read" - ReadUsers Permission = "users.read" + // ReadConsole opens the management console. Self-service members lack it so + // that they are routed to their profile instead. + ReadConsole Permission = "console.read" + // ReadUsers lists a tenant's members and their roles. + ReadUsers Permission = "users.read" + // ManageApplications registers applications and configures their client + // credentials. ManageApplications Permission = "applications.manage" - ManageKeys Permission = "keys.manage" - ManageContracts Permission = "contracts.manage" - ManageUsers Permission = "users.manage" - ManageTenants Permission = "tenants.manage" - EditProfile Permission = "profile.edit" + // ManageKeys issues and revokes API keys. + ManageKeys Permission = "keys.manage" + // ManageContracts imports OpenAPI contracts. + ManageContracts Permission = "contracts.manage" + // ManageUsers invites members and changes the roles and application grants + // of existing ones. + ManageUsers Permission = "users.manage" + // ManageRoles creates and edits the tenant's own roles. Kept separate from + // ManageUsers because defining roles is what defines privilege. + ManageRoles Permission = "roles.manage" + // ManageTenants creates tenants and administers the installation. + ManageTenants Permission = "tenants.manage" + // EditProfile changes one's own display name and picture. + EditProfile Permission = "profile.edit" ) -// catalog lists every role in descending order of privilege. -var catalog = []Role{Owner, Admin, Developer, Operator, Viewer, User} - -// grants is the permission matrix. ReadConsole is deliberately absent for User -// so that self-service members are routed to the profile screen instead of the -// management console. -var grants = map[Role]map[Permission]bool{ - Owner: { - ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true, - ManageContracts: true, ManageUsers: true, ManageTenants: true, EditProfile: true, - }, - Admin: { - ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true, - ManageContracts: true, ManageUsers: true, EditProfile: true, - }, - Developer: { - ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true, - ManageContracts: true, EditProfile: true, - }, - Operator: { - ReadConsole: true, ReadUsers: true, ManageKeys: true, ManageContracts: true, EditProfile: true, - }, - Viewer: { - ReadConsole: true, ReadUsers: true, EditProfile: true, - }, - User: { - EditProfile: true, - }, -} - +// ordered lists every permission from most to least sensitive. The order is the +// one the console renders checkboxes in. var ordered = []Permission{ - ReadConsole, ReadUsers, ManageApplications, ManageKeys, - ManageContracts, ManageUsers, ManageTenants, EditProfile, + ManageTenants, + ManageRoles, + ManageUsers, + ManageApplications, + ManageKeys, + ManageContracts, + ReadUsers, + ReadConsole, + EditProfile, } -// Entry describes a role for the console and for API clients. +var known = map[Permission]bool{ + ReadConsole: true, + ReadUsers: true, + ManageApplications: true, + ManageKeys: true, + ManageContracts: true, + ManageUsers: true, + ManageRoles: true, + ManageTenants: true, + EditProfile: true, +} + +// Entry describes a permission for the console and for API clients. type Entry struct { - Value string `json:"value"` - Label string `json:"label"` - Permissions []string `json:"permissions"` + Value string `json:"value"` + Label string `json:"label"` + Description string `json:"description"` } -var labels = map[Role]string{ - Owner: "Owner", - Admin: "Administrator", - Developer: "Developer", - Operator: "Operator", - Viewer: "Viewer", - User: "User", +var details = map[Permission]struct{ label, description string }{ + ReadConsole: {"Read console", "Open the management console and pick tenants."}, + ReadUsers: {"Read members", "List the members of a tenant and the roles they hold."}, + ManageApplications: {"Manage applications", "Register applications and configure client credentials."}, + ManageKeys: {"Manage API keys", "Issue and revoke API keys."}, + ManageContracts: {"Manage contracts", "Import OpenAPI contracts and their endpoints."}, + ManageUsers: {"Manage members", "Invite members and change their roles and application grants."}, + ManageRoles: {"Manage roles", "Create and edit the roles of this tenant."}, + ManageTenants: {"Manage tenants", "Create tenants and administer the installation."}, + EditProfile: {"Edit own profile", "Change one's own display name and picture."}, } -// All returns every role ordered from most to least privileged. -func All() []Role { - result := make([]Role, len(catalog)) - copy(result, catalog) +// Permissions returns every permission in display order. +func Permissions() []Permission { + result := make([]Permission, len(ordered)) + copy(result, ordered) return result } -// Catalog returns every role with its label and permissions so that the console -// and API clients never hardcode the role list. -func Catalog() []Entry { - result := make([]Entry, 0, len(catalog)) - for _, item := range catalog { - result = append(result, Entry{Value: string(item), Label: labels[item], Permissions: item.Permissions()}) - } - return result -} - -// Parse converts a stored or submitted value into a Role. -func Parse(value string) (Role, bool) { - candidate := Role(value) - if _, known := grants[candidate]; !known { +// Parse validates a submitted permission name. Unknown values are rejected so +// that a crafted payload cannot invent capabilities. +func Parse(value string) (Permission, bool) { + candidate := Permission(value) + if !known[candidate] { return "", false } return candidate, true } -// Valid reports whether the value names a role. -func (r Role) Valid() bool { - _, known := grants[r] - return known +// Valid reports whether the value names a permission. +func (p Permission) Valid() bool { + return known[p] } -// Allows reports whether the role grants the permission. -func (r Role) Allows(permission Permission) bool { - return grants[r][permission] +// Catalog returns every permission with a label and description so that the +// console and API clients never hardcode the list. +func Catalog() []Entry { + result := make([]Entry, 0, len(ordered)) + for _, permission := range ordered { + detail := details[permission] + result = append(result, Entry{ + Value: string(permission), + Label: detail.label, + Description: detail.description, + }) + } + return result } -// Permissions returns the role's permissions in a stable order. -func (r Role) Permissions() []string { +// Values returns every permission as strings, for use as a SQL array parameter. +func Values() []string { result := make([]string, 0, len(ordered)) for _, permission := range ordered { - if r.Allows(permission) { - result = append(result, string(permission)) - } - } - return result -} - -// Grants returns the roles that grant a permission, for use as a SQL parameter -// so that role checks in queries stay aligned with this matrix. -func Grants(permission Permission) []string { - result := make([]string, 0, len(catalog)) - for _, item := range catalog { - if item.Allows(permission) { - result = append(result, string(item)) - } - } - return result -} - -// Grantable returns the roles a tenant administrator may hand to a new member. -// Ownership is not grantable during an invitation because it is established by -// creating or claiming a tenant. -func Grantable() []Role { - result := make([]Role, 0, len(catalog)-1) - for _, item := range catalog { - if item != Owner { - result = append(result, item) - } - } - return result -} - -// GrantableValues returns Grantable as strings for a request payload. -func GrantableValues() []string { - values := Grantable() - result := make([]string, len(values)) - for index, item := range values { - result[index] = string(item) + result = append(result, string(permission)) } return result } diff --git a/internal/role/role_test.go b/internal/role/role_test.go index d22a107..749b3a5 100644 --- a/internal/role/role_test.go +++ b/internal/role/role_test.go @@ -6,130 +6,112 @@ import ( "testing" ) -func TestParseRejectsUnknownRoles(t *testing.T) { - tests := []struct { - value string - want bool - }{ - {value: "owner", want: true}, - {value: "admin", want: true}, - {value: "developer", want: true}, - {value: "operator", want: true}, - {value: "viewer", want: true}, - {value: "user", want: true}, - {value: "Owner", want: false}, - {value: "", want: false}, - {value: "superuser", want: false}, - {value: "owner; DROP TABLE memberships", want: false}, +func TestParseRejectsUnknownPermissions(t *testing.T) { + valid := []string{ + "console.read", "users.read", "applications.manage", "keys.manage", + "contracts.manage", "users.manage", "roles.manage", "tenants.manage", + "profile.edit", } - for _, test := range tests { - t.Run(test.value, func(t *testing.T) { - _, ok := Parse(test.value) - if ok != test.want { - t.Fatalf("Parse(%q) ok = %v, want %v", test.value, ok, test.want) - } - }) + for _, value := range valid { + if _, ok := Parse(value); !ok { + t.Errorf("Parse(%q) = not ok, want a permission", value) + } } -} - -func TestPermissionMatrix(t *testing.T) { - tests := []struct { - role Role - permission Permission - want bool - }{ - {role: Owner, permission: ManageTenants, want: true}, - {role: Owner, permission: ManageUsers, want: true}, - {role: Admin, permission: ManageTenants, want: false}, - {role: Admin, permission: ManageUsers, want: true}, - {role: Admin, permission: ManageApplications, want: true}, - {role: Developer, permission: ManageUsers, want: false}, - {role: Developer, permission: ManageApplications, want: true}, - {role: Developer, permission: ManageKeys, want: true}, - {role: Developer, permission: ManageContracts, want: true}, - {role: Operator, permission: ManageApplications, want: false}, - {role: Operator, permission: ManageKeys, want: true}, - {role: Operator, permission: ManageContracts, want: true}, - {role: Viewer, permission: ManageKeys, want: false}, - {role: Viewer, permission: ReadConsole, want: true}, - {role: User, permission: ReadConsole, want: false}, - {role: User, permission: ReadUsers, want: false}, - {role: User, permission: EditProfile, want: true}, - } - for _, test := range tests { - t.Run(string(test.role)+"/"+string(test.permission), func(t *testing.T) { - if got := test.role.Allows(test.permission); got != test.want { - t.Fatalf("Allows(%q) = %v, want %v", test.permission, got, test.want) - } - }) - } -} - -func TestEveryRoleCanEditItsOwnProfile(t *testing.T) { - for _, item := range All() { - if !item.Allows(EditProfile) { - t.Fatalf("role %q cannot edit its own profile", item) + for _, value := range []string{"", "Console.read", "console", "superuser", "users.manage; DROP TABLE roles"} { + if parsed, ok := Parse(value); ok { + t.Errorf("Parse(%q) = %q, want rejection", value, parsed) } } } -func TestGrantsMatchAllows(t *testing.T) { - for _, permission := range ordered { - granted := Grants(permission) - for _, item := range All() { - if slices.Contains(granted, string(item)) != item.Allows(permission) { - t.Fatalf("Grants(%q) and Allows disagree for role %q", permission, item) +func TestValidMatchesParse(t *testing.T) { + for _, permission := range Permissions() { + if !permission.Valid() { + t.Errorf("%q reports itself invalid", permission) + } + if parsed, ok := Parse(string(permission)); !ok || parsed != permission { + t.Errorf("Parse(%q) did not round-trip", permission) + } + } + if Permission("nope").Valid() { + t.Error("an unknown permission reports itself valid") + } +} + +func TestPermissionsAreDottedNames(t *testing.T) { + for _, permission := range Permissions() { + name := string(permission) + if len(name) < 3 || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { + t.Fatalf("%q is not a plausible permission name", name) + } + dot := false + for _, character := range name { + switch { + case character == '.': + dot = true + case character >= 'a' && character <= 'z', character >= '0' && character <= '9', character == '-': + default: + t.Fatalf("%q contains %q; permissions use lowercase words", name, character) } } - } -} - -func TestGrantsListsRolesInPrivilegeOrder(t *testing.T) { - if got := Grants(ManageKeys); !slices.Equal(got, []string{"owner", "admin", "developer", "operator"}) { - t.Fatalf("Grants(ManageKeys) = %v", got) - } - if got := Grants(ManageUsers); !slices.Equal(got, []string{"owner", "admin"}) { - t.Fatalf("Grants(ManageUsers) = %v", got) - } - if got := Grants(ReadConsole); !slices.Contains(got, string(Owner)) || slices.Contains(got, string(User)) { - t.Fatalf("Grants(ReadConsole) = %v", got) - } -} - -func TestCatalogCoversEveryRoleWithALabel(t *testing.T) { - entries := Catalog() - if len(entries) != len(catalog) { - t.Fatalf("Catalog() length = %d, want %d", len(entries), len(catalog)) - } - for _, entry := range entries { - if entry.Label == "" { - t.Fatalf("role %q has no label", entry.Value) - } - parsed, ok := Parse(entry.Value) - if !ok { - t.Fatalf("catalog entry %q is not a known role", entry.Value) - } - if len(entry.Permissions) != len(parsed.Permissions()) { - t.Fatalf("role %q catalog permissions drifted from the matrix", entry.Value) + if !dot { + t.Errorf("%q should be a namespaced name", name) } } } -func TestGrantableExcludesOwner(t *testing.T) { - for _, item := range Grantable() { - if item == Owner { - t.Fatal("owner must not be grantable through an invitation") - } +func TestCatalogDescribesEveryPermission(t *testing.T) { + catalog := Catalog() + if len(catalog) != len(Permissions()) { + t.Fatalf("catalog has %d entries, want %d", len(catalog), len(Permissions())) } - if len(GrantableValues()) != len(catalog)-1 { - t.Fatalf("GrantableValues() length = %d", len(GrantableValues())) + 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 } } -func TestPermissionsUseDottedNames(t *testing.T) { - for _, permission := range All()[0].Permissions() { - if !strings.Contains(permission, ".") { - t.Fatalf("permission %q should be a dotted capability name", permission) +// 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/clientauth.go b/internal/server/clientauth.go index f7e83cf..1b3ffaf 100644 --- a/internal/server/clientauth.go +++ b/internal/server/clientauth.go @@ -8,7 +8,6 @@ import ( "time" "github.com/danyel/go-loose/internal/key" - "github.com/danyel/go-loose/internal/role" "github.com/danyel/go-loose/internal/store" ) @@ -33,7 +32,7 @@ func (s *Server) clientUser(user store.ClientUser) clientUserResponse { ID: user.ID, Email: user.Email, DisplayName: user.DisplayName, AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID, TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application, - Role: user.Role, Permissions: role.Role(user.Role).Permissions(), + Role: user.Role, Permissions: user.Permissions, } } diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 25266a5..26f21bd 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -39,3 +39,100 @@ func TestRequireSameOriginRejectsDifferentTenantHost(t *testing.T) { t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) } } + +// TestRequireSameOriginSurvivesStaleBaseURL covers the deployment that made +// profile saves fail with "same-origin request required": the container was +// started with a base URL of "http://localhost:" while users browsed to +// https://auth.urpi.be. The configured base URL must not be the only accepted +// origin, or a stale value breaks every state-changing route at once. +func TestRequireSameOriginSurvivesStaleBaseURL(t *testing.T) { + for _, baseURL := range []string{"http://localhost:", "", "http://localhost:8080", "not a url"} { + server := &Server{cfg: config.Config{BaseURL: baseURL}} + handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodPut, "https://auth.urpi.be/api/v1/profile", nil) + request.Header.Set("Origin", "https://auth.urpi.be") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Errorf("base URL %q: status = %d, want %d", baseURL, response.Code, http.StatusNoContent) + } + } +} + +// TestRequireSameOriginAcceptsForwardedHTTPS covers a reverse proxy that +// terminates TLS: the server sees a plain HTTP connection, so the scheme has to +// come from X-Forwarded-Proto rather than from r.TLS. +func TestRequireSameOriginAcceptsForwardedHTTPS(t *testing.T) { + server := &Server{cfg: config.Config{BaseURL: "http://go-loose:8080"}} + handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodPut, "http://auth.urpi.be/api/v1/profile", nil) + request.Host = "auth.urpi.be" + request.Header.Set("Origin", "https://auth.urpi.be") + request.Header.Set("X-Forwarded-Proto", "https") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } +} + +func TestRequireSameOriginFallsBackToReferer(t *testing.T) { + server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}} + handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil) + request.Header.Set("Referer", "https://auth.dev/console") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } +} + +func TestRequireSameOriginRejectsMissingAndForeignSources(t *testing.T) { + cases := []struct { + name string + origin string + referer string + }{ + {name: "no origin or referer"}, + {name: "foreign origin", origin: "https://evil.example"}, + {name: "null origin", origin: "null"}, + {name: "same host different scheme", origin: "http://auth.dev"}, + {name: "origin is a prefix of the host", origin: "https://auth.dev.evil.example"}, + {name: "referer from another tenant", referer: "https://ypto.auth.dev/profile"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}} + handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("handler reached for a request that was not same-origin") + })) + request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil) + if testCase.origin != "" { + request.Header.Set("Origin", testCase.origin) + } + if testCase.referer != "" { + request.Header.Set("Referer", testCase.referer) + } + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } + }) + } +} diff --git a/internal/server/profile_test.go b/internal/server/profile_test.go index 9668698..87e2869 100644 --- a/internal/server/profile_test.go +++ b/internal/server/profile_test.go @@ -52,12 +52,17 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) { } } +// 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" payload := server.clientUser(store.ClientUser{ ID: "user-1", Email: "operator@example.test", DisplayName: "Operator", - TenantSlug: "nmbs", Application: "guess", Role: string(role.Operator), AvatarKey: &key, + 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) @@ -65,17 +70,21 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) { if !slices.Contains(payload.Permissions, string(role.ManageKeys)) { t.Fatalf("permissions = %v, want keys.manage", payload.Permissions) } - if slices.Contains(payload.Permissions, string(role.ManageUsers)) { - t.Fatalf("permissions = %v, must not grant users.manage to an operator", 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(store.ClientUser{Role: string(role.User)}) + payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}}) if payload.AvatarURL != "" { t.Fatalf("avatar url = %q, want empty", payload.AvatarURL) } @@ -138,38 +147,6 @@ func TestUpdateProfileRejectsOversizedBody(t *testing.T) { } } -func TestInvitableRoleExcludesOwner(t *testing.T) { - if _, ok := invitableRole(string(role.Owner)); ok { - t.Fatal("an invitation must not be able to grant ownership") - } - for _, item := range role.Grantable() { - if _, ok := invitableRole(string(item)); !ok { - t.Fatalf("invitableRole(%q) = false", item) - } - } - for _, item := range []string{"", "superuser", "Owner", "user'", "owner"} { - if item == string(role.Owner) { - continue - } - if _, ok := invitableRole(item); ok { - t.Fatalf("invitableRole(%q) = true", item) - } - } -} - -func TestAssignableRoleAcceptsEveryKnownRole(t *testing.T) { - for _, item := range role.All() { - if got, ok := assignableRole(string(item)); !ok || got != string(item) { - t.Fatalf("assignableRole(%q) = %q, %v", item, got, ok) - } - } - for _, item := range []string{"", "superuser", "Owner", "admin,owner"} { - if _, ok := assignableRole(item); ok { - t.Fatalf("assignableRole(%q) = true", item) - } - } -} - func TestProfilePageIsEmbedded(t *testing.T) { content, err := web.Files.ReadFile("profile.html") if err != nil { diff --git a/internal/server/roles.go b/internal/server/roles.go new file mode 100644 index 0000000..1bf5cbb --- /dev/null +++ b/internal/server/roles.go @@ -0,0 +1,151 @@ +package server + +import ( + "errors" + "net/http" + "strings" + + "github.com/danyel/go-loose/internal/store" +) + +const ( + maxRoleName = 100 + maxRoleDescription = 500 +) + +// rolesPage serves the role management screen. +func (s *Server) rolesPage(w http.ResponseWriter, _ *http.Request) { + s.serveEmbedded(w, "roles.html") +} + +// listRoles serves the role management screen's data: every built-in role plus +// the tenant's own, each annotated with whether the caller may assign it. +func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) { + tenantID, ok := s.requestTenant(w, r) + if !ok { + return + } + roles, err := s.store.ListRoles(r.Context(), claimsFrom(r).UserID, tenantID) + if err != nil { + s.handleStoreError(w, "list roles", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"roles": roles}) +} + +// createRole adds a role to the tenant. The store refuses a role that grants +// more than the caller holds, so the endpoint does not have to restate that rule. +func (s *Server) createRole(w http.ResponseWriter, r *http.Request) { + var request struct { + TenantID string `json:"tenant_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + } + if !decodeJSON(w, r, &request) { + return + } + slug := strings.TrimSpace(request.Slug) + name := strings.TrimSpace(request.Name) + description := strings.TrimSpace(request.Description) + if !validSlug(slug) { + writeError(w, http.StatusBadRequest, "slug must be lowercase letters, digits, and dashes") + return + } + if name == "" || len(name) > maxRoleName { + writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters") + return + } + if len(description) > maxRoleDescription { + writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer") + return + } + created, err := s.store.CreateRole( + r.Context(), claimsFrom(r).UserID, request.TenantID, slug, name, description, request.Permissions) + if err != nil { + if errors.Is(err, store.ErrRoleSlugTaken) { + writeError(w, http.StatusConflict, "a role with this slug already exists in this tenant") + return + } + if errors.Is(err, store.ErrUnknownPermission) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.handleStoreError(w, "create role", err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +// updateRole renames a role and replaces the permissions it grants. +func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) { + var request struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + } + if !decodeJSON(w, r, &request) { + return + } + name := strings.TrimSpace(request.Name) + description := strings.TrimSpace(request.Description) + if name == "" || len(name) > maxRoleName { + writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters") + return + } + if len(description) > maxRoleDescription { + writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer") + return + } + updated, err := s.store.UpdateRole( + r.Context(), claimsFrom(r).UserID, r.PathValue("id"), name, description, request.Permissions) + if err != nil { + if errors.Is(err, store.ErrUnknownPermission) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.handleStoreError(w, "update role", err) + return + } + writeJSON(w, http.StatusOK, updated) +} + +// deleteRole removes one of the tenant's own roles. +func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteRole(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil { + if errors.Is(err, store.ErrRoleInUse) { + writeError(w, http.StatusConflict, "reassign the members of this role before deleting it") + return + } + s.handleStoreError(w, "delete role", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// requestTenant reads the tenant a request targets, preferring an explicit +// tenant_id and falling back to the tenant named by the host so that the role +// screen works on a tenant subdomain. +func (s *Server) requestTenant(w http.ResponseWriter, r *http.Request) (string, bool) { + if tenantID := strings.TrimSpace(r.URL.Query().Get("tenant_id")); tenantID != "" { + return tenantID, true + } + slug := s.requestTenantSlug(r.Host) + if slug == "" { + writeError(w, http.StatusBadRequest, "tenant_id is required") + return "", false + } + tenants, err := s.store.ListTenants(r.Context(), claimsFrom(r).UserID) + if err != nil { + s.handleStoreError(w, "resolve tenant", err) + return "", false + } + for _, tenant := range tenants { + if tenant.Slug == slug { + return tenant.ID, true + } + } + writeError(w, http.StatusNotFound, "resource not found or permission denied") + return "", false +} diff --git a/internal/server/roles_test.go b/internal/server/roles_test.go new file mode 100644 index 0000000..fc8eb7d --- /dev/null +++ b/internal/server/roles_test.go @@ -0,0 +1,177 @@ +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":[]}`}, + {"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`}, + } + 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 7f9e775..b18be28 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -120,6 +120,7 @@ func (s *Server) Handler() http.Handler { // 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))) @@ -134,6 +135,10 @@ 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)))) return s.recover(s.logRequests(s.securityHeaders(mux))) } @@ -192,7 +197,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" { + if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" { http.NotFound(w, r) return } @@ -400,7 +405,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "user": claims, "profile": s.profileResponse(profile), "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts, - "users": users, "pending_users": pendingUsers, "roles": role.Catalog(), + "users": users, "pending_users": pendingUsers, "permissions": role.Catalog(), "is_system_administrator": isSystemAdministrator, }) } @@ -623,16 +628,24 @@ func (s *Server) requireSession(next http.Handler) http.Handler { }) } +// requireSameOrigin rejects state-changing requests that did not come from the +// page the session cookie belongs to. +// +// The comparison is made against the origin the request actually arrived on +// rather than against cfg.BaseURL alone. A deployment whose configured base URL +// disagrees with the address users browse to -- a reverse proxy terminating TLS, +// a tenant subdomain, or simply a stale GO_LOOSE_BASE_URL -- would otherwise +// reject every legitimate write. Deriving the scheme from the request keeps the +// check anchored to r.Host, which is the part an attacker cannot influence: the +// browser sets Origin itself and it always names the page's own host. func (s *Server) requireSameOrigin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { source := r.Header.Get("Origin") if source == "" { source = r.Header.Get("Referer") } - sourceURL, sourceErr := url.Parse(source) - baseURL, baseErr := url.Parse(s.cfg.BaseURL) - if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme || - !strings.EqualFold(sourceURL.Host, r.Host) { + sourceURL, err := url.Parse(source) + if err != nil || !s.originAllowed(sourceURL, r) { writeError(w, http.StatusForbidden, "same-origin request required") return } @@ -640,6 +653,56 @@ func (s *Server) requireSameOrigin(next http.Handler) http.Handler { }) } +// originAllowed reports whether source names the same origin as the request. +// The configured base URL is accepted as well so that installations which +// advertise a canonical address keep working when a request reaches the server +// through a different but still legitimate hostname. +func (s *Server) originAllowed(source *url.URL, r *http.Request) bool { + if source.Host == "" { + return false + } + baseScheme, baseHost := baseOrigin(s.cfg.BaseURL) + for _, candidate := range [][2]string{ + {s.requestScheme(r), r.Host}, + {baseScheme, baseHost}, + } { + scheme, host := candidate[0], candidate[1] + if host == "" { + continue + } + if strings.EqualFold(source.Host, host) && strings.EqualFold(source.Scheme, scheme) { + return true + } + } + return false +} + +// requestScheme reports the scheme the client used to reach this server. A +// reverse proxy terminates TLS before the request arrives, so the forwarded +// protocol takes precedence over the local connection state. +func (s *Server) requestScheme(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" { + if scheme, _, found := strings.Cut(forwarded, ","); found { + return strings.TrimSpace(scheme) + } + return strings.TrimSpace(forwarded) + } + if r.TLS != nil { + return "https" + } + return "http" +} + +// baseOrigin splits a configured base URL into its scheme and host, returning +// empty strings when the value is missing or unparseable. +func baseOrigin(baseURL string) (scheme, host string) { + parsed, err := url.Parse(baseURL) + if err != nil { + return "", "" + } + return parsed.Scheme, parsed.Host +} + func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/internal/server/useradmin.go b/internal/server/useradmin.go index c1b0f64..2e6bf5a 100644 --- a/internal/server/useradmin.go +++ b/internal/server/useradmin.go @@ -8,28 +8,8 @@ import ( "github.com/danyel/go-loose/internal/key" "github.com/danyel/go-loose/internal/password" - "github.com/danyel/go-loose/internal/role" ) -// assignableRole validates a role that an administrator may hand to an existing -// member. Any known role qualifies, including owner, so that ownership can move -// between accounts deliberately. -func assignableRole(value string) (string, bool) { - parsed, ok := role.Parse(value) - return string(parsed), ok -} - -// invitableRole validates a role for a new invitation. Ownership is established -// by creating or claiming a tenant, so it is never granted here. -func invitableRole(value string) (string, bool) { - for _, item := range role.Grantable() { - if string(item) == value { - return value, true - } - } - return "", false -} - func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) { var request struct { RedirectURIs []string `json:"redirect_uris"` @@ -79,7 +59,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { TenantID string `json:"tenant_id"` Email string `json:"email"` DisplayName string `json:"display_name"` - Role string `json:"role"` + RoleID string `json:"role_id"` Password string `json:"password"` } if !decodeJSON(w, r, &request) { @@ -95,9 +75,8 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { if request.DisplayName == "" { request.DisplayName = request.Email } - invitedRole, ok := invitableRole(request.Role) - if !ok { - writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", ")) + if strings.TrimSpace(request.RoleID) == "" { + writeError(w, http.StatusBadRequest, "role_id is required") return } passwordHash, err := password.Hash(request.Password) @@ -105,7 +84,7 @@ 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, invitedRole, passwordHash) + user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.RoleID, passwordHash) if err != nil { s.handleStoreError(w, "invite user", err) return @@ -117,18 +96,17 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) { func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) { var request struct { TenantID string `json:"tenant_id"` - Role string `json:"role"` + RoleID string `json:"role_id"` ApplicationIDs []string `json:"application_ids"` } if !decodeJSON(w, r, &request) { return } - membershipRole, ok := assignableRole(request.Role) - if !ok { - writeError(w, http.StatusBadRequest, "invalid role") + if strings.TrimSpace(request.RoleID) == "" { + writeError(w, http.StatusBadRequest, "role_id is required") return } - if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil { + if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.RoleID, request.ApplicationIDs); err != nil { s.handleStoreError(w, "update user access", err) return } @@ -138,20 +116,19 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) { func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) { var request struct { TenantID string `json:"tenant_id"` - Role string `json:"role"` + RoleID string `json:"role_id"` ApplicationIDs []string `json:"application_ids"` } if !decodeJSON(w, r, &request) { return } - membershipRole, ok := assignableRole(request.Role) - if !ok { - writeError(w, http.StatusBadRequest, "invalid role") + if strings.TrimSpace(request.RoleID) == "" { + writeError(w, http.StatusBadRequest, "role_id is required") return } if err := s.store.ApprovePendingUser( r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), - membershipRole, request.ApplicationIDs, + request.RoleID, request.ApplicationIDs, ); err != nil { s.handleStoreError(w, "approve pending user", err) return diff --git a/internal/store/clientauth.go b/internal/store/clientauth.go index eaf513c..60d00ee 100644 --- a/internal/store/clientauth.go +++ b/internal/store/clientauth.go @@ -16,6 +16,7 @@ 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"` @@ -46,7 +47,9 @@ type ClientUser struct { // 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"` + 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:"-"` @@ -55,9 +58,13 @@ type ClientUser struct { func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT u.id, m.tenant_id, u.email, u.display_name, m.role, + SELECT u.id, m.tenant_id, u.email, u.display_name, r.slug, m.role_id, CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END, u.avatar_key, + COALESCE(( + SELECT json_agg(rp.permission ORDER BY rp.permission) + FROM role_permissions rp WHERE rp.role_id = m.role_id + ), '[]'::json), COALESCE(( SELECT json_agg(ua.application_id ORDER BY ua.application_id) FROM user_application_access ua @@ -66,12 +73,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ ), '[]'::json) FROM users u JOIN memberships m ON m.user_id = u.id - WHERE EXISTS ( - SELECT 1 FROM memberships own - WHERE own.tenant_id = m.tenant_id AND own.user_id = $1 - AND own.role = ANY($2) - ) - ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers)) + JOIN roles r ON r.id = m.role_id + WHERE `+permissionPredicate("m.tenant_id", "$1", "$2")+` + ORDER BY u.email, m.tenant_id`, administratorID, string(role.ManageUsers)) if err != nil { return nil, err } @@ -79,37 +83,42 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([ result := make([]ManagedUser, 0) for rows.Next() { var user ManagedUser - var applicationIDs []byte + var permissions, applicationIDs []byte if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, - &user.Status, &user.AvatarKey, &applicationIDs); err != nil { + &user.RoleID, &user.Status, &user.AvatarKey, &permissions, &applicationIDs); err != nil { + return nil, err + } + if err := json.Unmarshal(permissions, &user.Permissions); err != nil { return nil, err } if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil { return nil, err } - user.Permissions = role.Role(user.Role).Permissions() result = append(result, user) } return result, rows.Err() } -func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, membershipRole, passwordHash string) (ManagedUser, error) { +// InviteUser adds a member to a tenant or, when the email already exists, +// re-invites them. The caller must hold users.manage in that tenant and may only +// hand out a role whose permissions they hold themselves. +func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, roleID string, passwordHash string) (ManagedUser, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return ManagedUser{}, err } defer tx.Rollback() - var permitted bool - if err := tx.QueryRowContext(ctx, ` - SELECT EXISTS( - SELECT 1 FROM memberships - WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3) - )`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil { + held, err := heldPermissions(ctx, tx, administratorID, tenantID) + if err != nil { return ManagedUser{}, err } - if !permitted { + if !within([]string{string(role.ManageUsers)}, held) { return ManagedUser{}, ErrNotFound } + resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held) + 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) @@ -125,37 +134,65 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email return ManagedUser{}, err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3) - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, - tenantID, user.ID, membershipRole); err != nil { + INSERT INTO memberships(tenant_id, user_id, role_id) VALUES ($1, $2, $3) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`, + tenantID, user.ID, resolved); err != nil { return ManagedUser{}, err } if err := tx.Commit(); err != nil { return ManagedUser{}, err } - user.TenantID, user.Role, user.ApplicationIDs = tenantID, membershipRole, []string{} - user.Permissions = role.Role(membershipRole).Permissions() + granted, err := roleGrants(ctx, s.db, resolved) + if err != nil { + return ManagedUser{}, err + } + user.TenantID, user.RoleID, user.ApplicationIDs = tenantID, resolved, []string{} + if err := s.db.QueryRowContext(ctx, `SELECT slug FROM roles WHERE id = $1`, resolved).Scan(&user.Role); err != nil { + return ManagedUser{}, err + } + user.Permissions = granted return user, nil } -func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error { +// SetUserAccess changes a member's role and replaces their application grants. +func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() + held, err := heldPermissions(ctx, tx, administratorID, tenantID) + if err != nil { + return err + } + if !within([]string{string(role.ManageUsers)}, held) { + return ErrNotFound + } + resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held) + if err != nil { + return err + } + if err := ensureTenantKeepsAnOwner(ctx, tx, tenantID, userID, resolved); err != nil { + return err + } result, err := tx.ExecContext(ctx, ` - UPDATE memberships target SET role = $4 - FROM memberships administrator - WHERE target.tenant_id = $1 AND target.user_id = $2 - AND administrator.tenant_id = target.tenant_id - AND administrator.user_id = $3 AND administrator.role = ANY($5)`, - tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers)) + UPDATE memberships SET role_id = $3 + WHERE tenant_id = $1 AND user_id = $2 AND role_id IS DISTINCT FROM $3`, + tenantID, userID, resolved) if err != nil { return err } if count, _ := result.RowsAffected(); count == 0 { - return ErrNotFound + // Either the membership does not exist or the role is unchanged. + var exists bool + if err := tx.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)`, + tenantID, userID).Scan(&exists); err != nil { + return err + } + if !exists { + return ErrNotFound + } } if _, err := tx.ExecContext(ctx, ` DELETE FROM user_application_access ua @@ -179,17 +216,49 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us return tx.Commit() } +// ErrLastOwner is returned when a change would leave a tenant without an owner. +var ErrLastOwner = errors.New("a tenant must keep at least one owner") + +// ensureTenantKeepsAnOwner blocks demoting or moving away the only membership +// that still holds the owner role. Without this a tenant administrator could +// lock every administrator out of their own tenant with a single edit. +func ensureTenantKeepsAnOwner(ctx context.Context, tx *sql.Tx, tenantID, userID, newRoleID string) error { + var losesOwner bool + if err := tx.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM memberships m + JOIN roles r ON r.id = m.role_id + WHERE m.tenant_id = $1 AND m.user_id = $2 AND r.slug = 'owner' AND m.role_id <> $3 + )`, tenantID, userID, newRoleID).Scan(&losesOwner); err != nil { + return err + } + if !losesOwner { + return nil + } + var remaining int + if err := tx.QueryRowContext(ctx, ` + SELECT count(*) + FROM memberships m + JOIN roles r ON r.id = m.role_id + WHERE m.tenant_id = $1 AND r.slug = 'owner' AND m.user_id <> $2`, + tenantID, userID).Scan(&remaining); err != nil { + return err + } + if remaining == 0 { + return ErrLastOwner + } + return nil +} + func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) { var app Application var encoded []byte err := s.db.QueryRowContext(ctx, ` UPDATE applications a SET redirect_uris = $4, client_secret_hash = $5, 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 = ANY($3) + WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$2", "$3")+` RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`, - applicationID, administratorID, role.Grants(role.ManageApplications), redirectURIs, secretHash). + applicationID, administratorID, string(role.ManageApplications), redirectURIs, secretHash). Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady) if errors.Is(err, sql.ErrNoRows) { return Application{}, ErrNotFound @@ -263,6 +332,7 @@ 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 @@ -270,21 +340,25 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec JOIN user_application_access ua ON ua.application_id = a.id JOIN users u ON u.id = ua.user_id LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id + LEFT JOIN roles r ON r.id = m.role_id WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now() AND c.redirect_uri = $2 AND a.id = c.application_id AND ua.user_id = c.user_id AND ua.application_id = a.id AND a.client_id = $3 AND a.client_secret_hash = $4 RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug, - COALESCE(m.role, $5), u.avatar_key`, - codeHash, redirectURI, clientID, secretHash, role.User). + COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`, + codeHash, redirectURI, clientID, secretHash). Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, - &user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey) + &user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey) if errors.Is(err, sql.ErrNoRows) { return ClientUser{}, time.Time{}, ErrNotFound } if err != nil { return ClientUser{}, time.Time{}, err } + if err := json.Unmarshal(permissions, &user.Permissions); err != nil { + return ClientUser{}, time.Time{}, err + } expiresAt := time.Now().UTC().Add(12 * time.Hour) if _, err := tx.ExecContext(ctx, ` INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at) @@ -296,6 +370,7 @@ 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 @@ -303,17 +378,24 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client JOIN user_application_access ua ON ua.application_id = a.id JOIN users u ON u.id = ua.user_id LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id + LEFT JOIN roles r ON r.id = m.role_id WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() AND a.id = s.application_id AND ua.user_id = s.user_id RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug, - COALESCE(m.role, $2), u.avatar_key`, - tokenHash, role.User). + 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, &user.AvatarKey) + &user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey) if errors.Is(err, sql.ErrNoRows) { return ClientUser{}, ErrNotFound } - return user, err + if err != nil { + return ClientUser{}, err + } + if err := json.Unmarshal(permissions, &user.Permissions); err != nil { + return ClientUser{}, err + } + return user, nil } func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error { diff --git a/internal/store/installation.go b/internal/store/installation.go index 10b9917..87b693c 100644 --- a/internal/store/installation.go +++ b/internal/store/installation.go @@ -80,9 +80,11 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser return err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3) - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, - tenantID, userID, role.Admin); err != nil { + INSERT INTO memberships(tenant_id, user_id, role_id) + SELECT $1, $2, r.id FROM roles r + WHERE r.slug = $3 AND r.tenant_id IS NULL + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`, + tenantID, userID, "admin"); err != nil { return err } if _, err := tx.ExecContext(ctx, ` @@ -115,8 +117,10 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string return false, err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role) - SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil { + INSERT INTO memberships(tenant_id, user_id, role_id) + SELECT id, $1, r.id FROM tenants, roles r + WHERE r.slug = 'owner' AND r.tenant_id IS NULL + ON CONFLICT DO NOTHING`, userID); err != nil { return false, err } if _, err := tx.ExecContext(ctx, ` @@ -128,14 +132,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string } // HasConsoleAccess reports whether the user may read the management console. -// System administrators always qualify; otherwise the membership role must +// System administrators always qualify; otherwise one of their memberships must // grant console.read, which excludes self-service User members. func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) { var allowed bool err := s.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1) - OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1 AND role = ANY($2))`, - userID, role.Grants(role.ReadConsole)).Scan(&allowed) + OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"), + userID, string(role.ReadConsole)).Scan(&allowed) return allowed, err } @@ -183,7 +187,11 @@ func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([ return result, rows.Err() } -func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error { +// ApprovePendingUser gives a waiting-room visitor their first membership. +// Only a system administrator may do this, so the escalation guard that +// normally limits role assignment does not apply: the administrator is acting +// across tenants rather than inside one. +func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error { isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID) if err != nil { return err @@ -196,11 +204,17 @@ func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantI return err } defer tx.Rollback() + if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil { + return err + } result, err := tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role) + INSERT INTO memberships(tenant_id, user_id, role_id) SELECT $1, id, $3 FROM users WHERE id = $2 - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, - tenantID, userID, role) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`, + tenantID, userID, roleID) + if err != nil { + return err + } if err != nil { return err } diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go index 692ea66..ce80ece 100644 --- a/internal/store/integration_test.go +++ b/internal/store/integration_test.go @@ -107,10 +107,14 @@ func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug st return tenant } -// addMember invites a member into the tenant with the given role. +// 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() - member, err := s.InviteUser(ctx, owner, tenantID, email, email, membershipRole, "not-a-real-hash") + 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) } @@ -124,21 +128,21 @@ 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 []role.Role{role.Developer, role.Operator, role.User} { - addMember(t, s, ctx, owner, tenant.ID, string(item)+"@example.test", string(item)) + 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 TestMembershipRoleConstraintRejectsUnknownRole(t *testing.T) { +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", string(role.Viewer)) - if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "superuser", nil); err == nil { - t.Fatal("the database must reject a role outside the permission matrix") + 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") } } @@ -153,24 +157,26 @@ func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) { t.Fatalf("owner cannot create an application: %v", err) } tests := []struct { - role role.Role + role string canCreateApp bool canIssueKey bool canSaveContract bool canListManaged bool canConfigureClien bool }{ - {role: role.Owner, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, - {role: role.Admin, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true}, - {role: role.Developer, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: true}, - {role: role.Operator, canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: false}, - {role: role.Viewer, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false}, - {role: role.User, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false}, + // 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(string(test.role), func(t *testing.T) { - member := addMember(t, s, ctx, owner, tenant.ID, string(test.role)+"@example.test", string(test.role)) - slug := strings.ReplaceAll(string(test.role), "owner", "own") + 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{}, @@ -201,8 +207,8 @@ 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", string(role.User)) - operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", string(role.Operator)) + 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 { @@ -239,20 +245,25 @@ 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", string(role.Developer)) + 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 != string(role.Developer) { + 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) } - if contains(tenants[0].Permissions, string(role.ManageUsers)) { - t.Fatalf("developer permissions = %v, must not manage users", 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) } } @@ -260,7 +271,7 @@ 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", string(role.Operator)) + member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator") before, err := s.Profile(ctx, member) if err != nil { @@ -269,7 +280,7 @@ func TestProfileRoundTrip(t *testing.T) { if before.AvatarKey != nil || before.NameCustomized { t.Fatalf("fresh profile = %+v", before) } - if len(before.Memberships) != 1 || before.Memberships[0].Role != string(role.Operator) { + 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)) { @@ -329,7 +340,7 @@ 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", string(role.Viewer)) + member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer") key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl" payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4} @@ -425,7 +436,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) { if err != nil { t.Fatalf("create application: %v", err) } - member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator)) + 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) } @@ -443,7 +454,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) { if err != nil { t.Fatalf("exchange authorization code: %v", err) } - if user.Role != string(role.Operator) { + if user.Role != "operator" { t.Fatalf("role = %q, want operator", user.Role) } if user.AvatarKey == nil { @@ -453,7 +464,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) { if err != nil { t.Fatalf("client user by token: %v", err) } - if byToken.Role != string(role.Operator) || byToken.AvatarKey == nil { + if byToken.Role != "operator" || byToken.AvatarKey == nil { t.Fatalf("client user = %+v", byToken) } } @@ -480,8 +491,8 @@ func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) { if err != nil { t.Fatalf("exchange authorization code: %v", err) } - if user.Role != string(role.User) { - t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, role.User) + if user.Role != "user" { + t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, "user") } } diff --git a/internal/store/profile.go b/internal/store/profile.go index 4296f4e..7f4e769 100644 --- a/internal/store/profile.go +++ b/internal/store/profile.go @@ -3,10 +3,9 @@ package store import ( "context" "database/sql" + "encoding/json" "errors" "time" - - "github.com/danyel/go-loose/internal/role" ) // Membership is one tenant role held by a user. @@ -15,6 +14,7 @@ type Membership struct { TenantSlug string `json:"tenant_slug"` TenantName string `json:"tenant_name"` Role string `json:"role"` + RoleID string `json:"role_id"` Permissions []string `json:"permissions"` } @@ -77,9 +77,14 @@ func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) { } profile.Memberships = []Membership{} rows, err := s.db.QueryContext(ctx, ` - SELECT m.tenant_id, t.slug, t.name, m.role + 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 { @@ -88,10 +93,14 @@ func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) { defer rows.Close() for rows.Next() { var item Membership - if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role); err != nil { + 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 } - item.Permissions = role.Role(item.Role).Permissions() profile.Memberships = append(profile.Memberships, item) } return profile, rows.Err() diff --git a/internal/store/roles.go b/internal/store/roles.go new file mode 100644 index 0000000..d317f5b --- /dev/null +++ b/internal/store/roles.go @@ -0,0 +1,455 @@ +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 is defined with a permission + // outside the vocabulary. It is a caller mistake rather than a server fault, + // so callers report it as a bad request. + ErrUnknownPermission = 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 validates submitted permission names and returns them +// deduplicated in vocabulary order, so a role cannot be stored with an invented +// capability or an unstable order. +func normalizePermissions(permissions []string) ([]string, error) { + requested := make(map[string]bool, len(permissions)) + for _, value := range permissions { + parsed, ok := role.Parse(value) + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, value) + } + requested[string(parsed)] = true + } + result := make([]string, 0, len(requested)) + for _, permission := range role.Permissions() { + if requested[string(permission)] { + result = append(result, string(permission)) + } + } + return result, nil +} + +// 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) { + granted, err := normalizePermissions(permissions) + if err != nil { + return Role{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Role{}, err + } + defer tx.Rollback() + + held, err := heldPermissions(ctx, tx, actorID, tenantID) + if err != nil { + return Role{}, err + } + if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) { + 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) { + granted, err := normalizePermissions(permissions) + if err != nil { + return Role{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Role{}, err + } + defer tx.Rollback() + + existing, err := lockRole(ctx, tx, roleID) + if err != nil { + return Role{}, err + } + if existing.System || existing.TenantID == nil { + return Role{}, ErrNotFound + } + held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID) + if err != nil { + return Role{}, err + } + if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) { + 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 + } + held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID) + if err != nil { + return err + } + if !within([]string{string(role.ManageRoles)}, held) || !within(permissions, held) { + 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 new file mode 100644 index 0000000..c4fc7ad --- /dev/null +++ b/internal/store/roles_integration_test.go @@ -0,0 +1,242 @@ +package store_test + +import ( + "context" + "errors" + "testing" + + "github.com/danyel/go-loose/internal/role" + "github.com/danyel/go-loose/internal/store" +) + +// rolesFixture creates a tenant owned by owner plus a developer member. The +// developer is the interesting actor: it administers members but does not hold +// roles.manage, so it is the boundary the escalation guards are tested against. +func rolesFixture(t *testing.T) (s *store.Store, ctx context.Context, owner, developer, tenantID string) { + t.Helper() + s, _, ctx = integrationStore(t) + owner = newUser(t, s, ctx, "owner@example.test") + tenantID = newTenant(t, s, ctx, owner, "nmbs").ID + developer = addMember(t, s, ctx, owner, tenantID, "developer@example.test", "developer") + return s, ctx, owner, developer, tenantID +} + +func systemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string { + t.Helper() + id, err := s.SystemRoleID(ctx, slug) + if err != nil { + t.Fatalf("look up built-in role %q: %v", slug, err) + } + return id +} + +func TestTenantCanDefineItsOwnRoleAndItIsEnforced(t *testing.T) { + s, ctx, owner, _, tenantID := rolesFixture(t) + application, err := s.CreateApplication(ctx, owner, store.Application{ + TenantID: tenantID, Slug: "guess", Name: "Guess", AllowedHosts: []string{}, + }) + if err != nil { + t.Fatalf("create application: %v", err) + } + + created, err := s.CreateRole(ctx, owner, tenantID, "release-manager", + "Release manager", "Prepares releases.", + []string{string(role.ReadConsole), string(role.ManageContracts), string(role.ReadUsers)}) + if err != nil { + t.Fatalf("create role: %v", err) + } + if created.System { + t.Error("a tenant role must not be marked as built in") + } + if created.TenantID == nil || *created.TenantID != tenantID { + t.Errorf("tenant id = %v, want %s", created.TenantID, tenantID) + } + if !contains(created.Permissions, string(role.ManageContracts)) { + t.Errorf("permissions = %v, want contracts.manage", created.Permissions) + } + + // The role grants exactly what it says and nothing more. + member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer") + if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil { + t.Fatalf("assign the custom role: %v", err) + } + _, err = s.CreateAPIKey(ctx, member, application.ID, "key", "gl_prefix", secretHash("custom-denied"), nil) + assertPermission(t, "custom role issuing a key", err, false) + _, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil) + assertPermission(t, "custom role importing a contract", err, true) +} + +func TestCustomRoleIsScopedToItsOwnTenant(t *testing.T) { + s, ctx, owner, _, tenantID := rolesFixture(t) + other := newUser(t, s, ctx, "other-owner@example.test") + otherTenant := newTenant(t, s, ctx, other, "ypto").ID + + created, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", []string{string(role.ReadConsole)}) + if err != nil { + t.Fatalf("create role: %v", err) + } + + roles, err := s.ListRoles(ctx, other, otherTenant) + if err != nil { + t.Fatalf("list roles: %v", err) + } + for _, item := range roles { + if item.ID == created.ID { + t.Fatal("a role leaked across tenants") + } + } + if err := s.DeleteRole(ctx, other, created.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("cross-tenant delete = %v, want ErrNotFound", err) + } + if _, err := s.UpdateRole(ctx, other, created.ID, "Hijacked", "", nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("cross-tenant update = %v, want ErrNotFound", err) + } +} + +func TestBuiltInRolesCannotBeEditedOrDeleted(t *testing.T) { + s, ctx, owner, _, _ := rolesFixture(t) + ownerRole := systemRole(t, s, ctx, "owner") + if _, err := s.UpdateRole(ctx, owner, ownerRole, "Supreme", "", nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("editing a built-in role = %v, want ErrNotFound", err) + } + if err := s.DeleteRole(ctx, owner, ownerRole); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("deleting a built-in role = %v, want ErrNotFound", err) + } +} + +func TestRoleInUseCannotBeDeleted(t *testing.T) { + s, ctx, owner, _, tenantID := rolesFixture(t) + created, err := s.CreateRole(ctx, owner, tenantID, "support", "Support", "", []string{string(role.ReadConsole)}) + if err != nil { + t.Fatalf("create role: %v", err) + } + member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer") + if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil { + t.Fatalf("assign role: %v", err) + } + if err := s.DeleteRole(ctx, owner, created.ID); !errors.Is(err, store.ErrRoleInUse) { + t.Fatalf("delete an assigned role = %v, want ErrRoleInUse", err) + } + // Once nobody holds it the delete goes through. + if err := s.SetUserAccess(ctx, owner, tenantID, member, systemRole(t, s, ctx, "viewer"), nil); err != nil { + t.Fatalf("move the member off the role: %v", err) + } + if err := s.DeleteRole(ctx, owner, created.ID); err != nil { + t.Fatalf("delete an unused role: %v", err) + } +} + +// TestRoleAssignmentCannotEscalate is the guard that matters most now that +// developer and operator may manage members: they must not be able to hand +// somebody a role more powerful than their own, which would otherwise be a +// straightforward route to owner. +func TestRoleAssignmentCannotEscalate(t *testing.T) { + s, ctx, owner, developer, tenantID := rolesFixture(t) + victim := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer") + ownerRole := systemRole(t, s, ctx, "owner") + + if err := s.SetUserAccess(ctx, developer, tenantID, victim, ownerRole, nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("developer promoting a member to owner = %v, want ErrNotFound", err) + } + if _, err := s.InviteUser(ctx, developer, tenantID, "sneaky@example.test", "Sneaky", ownerRole, "hash"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("developer inviting as owner = %v, want ErrNotFound", err) + } + if _, err := s.CreateRole(ctx, developer, tenantID, "shadow-owner", "Shadow", "", + []string{string(role.ReadConsole), string(role.ManageTenants)}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("developer creating an owner-grade role = %v, want ErrNotFound", err) + } + // Nor may they define any role at all: roles.manage is not theirs to hold, + // however weak the role they were trying to mint. + if _, err := s.CreateRole(ctx, developer, tenantID, "teammate", "Teammate", "", + []string{string(role.ReadConsole), string(role.ReadUsers)}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("developer creating any role = %v, want ErrNotFound", err) + } + // The developer can still maintain members using the roles already offered. + if _, err := s.InviteUser(ctx, developer, tenantID, "teammate@example.test", "Teammate", + systemRole(t, s, ctx, "viewer"), "hash"); err != nil { + t.Fatalf("developer inviting a member: %v", err) + } +} + +func TestRolesManageIsRequiredToDefineRoles(t *testing.T) { + s, ctx, _, developer, tenantID := rolesFixture(t) + if _, err := s.ListRoles(ctx, developer, tenantID); err != nil { + t.Fatalf("a developer may read the role catalog: %v", err) + } + if _, err := s.CreateRole(ctx, developer, tenantID, "nope", "Nope", "", + []string{string(role.ReadConsole)}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("developer creating a role = %v, want ErrNotFound", err) + } +} + +func TestAssignableRolesExcludeAnythingAboveTheActor(t *testing.T) { + s, ctx, _, developer, tenantID := rolesFixture(t) + assignable, err := s.AssignableRoles(ctx, developer, tenantID) + if err != nil { + t.Fatalf("assignable roles: %v", err) + } + ownerRole := systemRole(t, s, ctx, "owner") + found := 0 + for _, item := range assignable { + if !item.Assignable { + t.Errorf("role %q was returned but is not marked assignable", item.Slug) + } + if contains(item.Permissions, string(role.ManageTenants)) { + t.Errorf("developer may assign %q, which grants tenants.manage", item.Slug) + } + if item.ID == ownerRole { + t.Error("a developer must not be offered the owner role") + } + found++ + } + if found == 0 { + t.Fatal("a developer should still be offered the roles within their authority") + } +} + +func TestUnknownPermissionIsRejectedWhenDefiningARole(t *testing.T) { + s, ctx, owner, _, tenantID := rolesFixture(t) + _, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "", + []string{string(role.ReadConsole), "superpower"}) + if err == nil { + t.Fatal("a role must not be creatable with an unknown permission") + } +} + +func TestDuplicateRoleSlugIsRejected(t *testing.T) { + s, ctx, owner, _, tenantID := rolesFixture(t) + if _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", + []string{string(role.ReadConsole)}); err != nil { + t.Fatalf("create role: %v", err) + } + _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Another", "", + []string{string(role.ReadConsole)}) + if !errors.Is(err, store.ErrRoleSlugTaken) { + t.Fatalf("duplicate slug = %v, want ErrRoleSlugTaken", err) + } +} + +// TestTenantKeepsAnOwner guards the demotion path: the last owner of a tenant +// must not be able to walk away from it and lock every administrator out. +func TestTenantKeepsAnOwner(t *testing.T) { + s, ctx, owner, _, tenantID := rolesFixture(t) + admin := addMember(t, s, ctx, owner, tenantID, "admin@example.test", "admin") + adminRole := systemRole(t, s, ctx, "admin") + ownerRole := systemRole(t, s, ctx, "owner") + + // The owner cannot simply step down while nobody else holds the role. + if err := s.SetUserAccess(ctx, owner, tenantID, owner, adminRole, nil); !errors.Is(err, store.ErrLastOwner) { + t.Fatalf("owner stepping down alone = %v, want ErrLastOwner", err) + } + // Handing the role over first makes the step down legal. + if err := s.SetUserAccess(ctx, owner, tenantID, admin, ownerRole, nil); err != nil { + t.Fatalf("promote the admin to owner: %v", err) + } + if err := s.SetUserAccess(ctx, admin, tenantID, owner, adminRole, nil); err != nil { + t.Fatalf("step down once a second owner exists: %v", err) + } + // The admin is now the only owner and must not be able to walk away from it. + if err := s.SetUserAccess(ctx, admin, tenantID, admin, adminRole, nil); !errors.Is(err, store.ErrLastOwner) { + t.Fatalf("demoting the last owner = %v, want ErrLastOwner", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 24f7f80..dd55116 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -30,8 +30,8 @@ type Tenant struct { Slug string `json:"slug"` Name string `json:"name"` Role string `json:"role"` - // Permissions is derived from Role so that the console and API clients read - // capabilities instead of re-implementing the role matrix. + // 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"` } @@ -130,11 +130,13 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin return err } if _, err = tx.ExecContext(ctx, ` - INSERT INTO memberships(tenant_id, user_id, role) - SELECT $1, $2, $3 - 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, role.Owner); err != nil { + INSERT INTO memberships(tenant_id, user_id, role_id) + SELECT $1, $2, r.id + FROM roles r + WHERE r.slug = $3 AND r.tenant_id IS NULL + AND (NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1) + OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)) + ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil { return err } if _, err = tx.ExecContext(ctx, ` @@ -157,9 +159,15 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT t.id, t.slug, t.name, m.role + SELECT t.id, t.slug, t.name, r.slug, + COALESCE(( + SELECT json_agg(rp.permission ORDER BY rp.permission) + FROM role_permissions rp + WHERE rp.role_id = m.role_id + ), '[]'::json) FROM tenants t JOIN memberships m ON m.tenant_id = t.id + JOIN roles r ON r.id = m.role_id WHERE m.user_id = $1 ORDER BY t.name`, userID) if err != nil { @@ -169,10 +177,13 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error result := make([]Tenant, 0) for rows.Next() { var item Tenant - if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil { + var permissions []byte + if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role, &permissions); err != nil { + return nil, err + } + if err := json.Unmarshal(permissions, &item.Permissions); err != nil { return nil, err } - item.Permissions = role.Role(item.Role).Permissions() result = append(result, item) } return result, rows.Err() @@ -190,11 +201,26 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te if err != nil { return Tenant{}, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil { + // Whoever creates a tenant owns it. + var permissions []byte + err = tx.QueryRowContext(ctx, ` + INSERT INTO memberships(tenant_id, user_id, role_id) + SELECT $1, $2, r.id FROM roles r + WHERE r.slug = 'owner' AND r.tenant_id IS NULL + RETURNING (SELECT slug FROM roles WHERE slug = 'owner' AND tenant_id IS NULL), + COALESCE(( + SELECT json_agg(rp.permission ORDER BY rp.permission) + FROM role_permissions rp + JOIN roles granted ON granted.id = rp.role_id + WHERE granted.slug = 'owner' AND granted.tenant_id IS NULL + ), '[]'::json)`, + tenant.ID, userID).Scan(&tenant.Role, &permissions) + if err != nil { + return Tenant{}, err + } + if err := json.Unmarshal(permissions, &tenant.Permissions); err != nil { return Tenant{}, err } - tenant.Role = string(role.Owner) - tenant.Permissions = role.Owner.Permissions() if err := tx.Commit(); err != nil { return Tenant{}, err } @@ -237,14 +263,11 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic row := s.db.QueryRowContext(ctx, ` INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts) SELECT $1, $2, $3, $4, $5 - WHERE EXISTS ( - SELECT 1 FROM memberships - WHERE tenant_id = $1 AND user_id = $6 AND role = ANY($7) - ) + WHERE `+permissionPredicate("$1", "$6", "$7")+` RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts), client_id, to_json(redirect_uris), false`, app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID, - role.Grants(role.ManageApplications)) + string(role.ManageApplications)) var allowedHosts, redirectURIs []byte err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady) if errors.Is(err, sql.ErrNoRows) { @@ -287,10 +310,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by) SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6 FROM applications a - JOIN memberships m ON m.tenant_id = a.tenant_id - WHERE a.id = $1 AND m.user_id = $6 AND m.role = ANY($7) + WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$6", "$7")+` RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`, - applicationID, name, prefix, hash, expiresAt, userID, role.Grants(role.ManageKeys)). + applicationID, name, prefix, hash, expiresAt, userID, string(role.ManageKeys)). Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return APIKey{}, ErrNotFound @@ -301,9 +323,8 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error { result, err := s.db.ExecContext(ctx, ` UPDATE api_keys k SET status = 'revoked', revoked_at = now() - FROM memberships m - WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`, - keyID, userID, role.Grants(role.ManageKeys)) + WHERE k.id = $1 AND `+permissionPredicate("k.tenant_id", "$2", "$3"), + keyID, userID, string(role.ManageKeys)) if err != nil { return err } @@ -363,11 +384,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string, INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by) SELECT a.id, $2, $3, $4, $5 FROM applications a - JOIN memberships m ON m.tenant_id = a.tenant_id - WHERE a.id = $1 AND m.user_id = $5 AND m.role = ANY($6) + WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$5", "$6")+` RETURNING id, application_id, version, source_url, created_at`, applicationID, document.Version, sourceURL, document.JSON, userID, - role.Grants(role.ManageContracts)). + string(role.ManageContracts)). Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return Contract{}, ErrNotFound diff --git a/web/app.js b/web/app.js index 6f5535a..462ba3e 100644 --- a/web/app.js +++ b/web/app.js @@ -1,15 +1,12 @@ const state = { tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], - roles: [], profile: null, selectedTenant: '' + roles: [], permissions: [], profile: null, selectedTenant: '' }; // Capability names mirror internal/role. The server sends the grants for the // selected tenant so the console never re-implements the matrix. const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission)); const $ = selector => document.querySelector(selector); -const roleOptions = roles => roles - .map(item => ``) - .join(''); const $$ = selector => [...document.querySelectorAll(selector)]; const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', @@ -48,11 +45,28 @@ 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(); renderIdentity(); - renderRoleOptions(); 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 = []; + } +} + function renderIdentity() { const profile = state.profile || {}; $('#identity-name').textContent = profile.display_name || profile.email || ''; @@ -66,11 +80,15 @@ function renderIdentity() { } } -function renderRoleOptions() { - // Inviting an owner is rejected by the server, so leave it out of the list. - const invitable = state.roles.filter(item => item.value !== 'owner'); - $('#invite-role').innerHTML = roleOptions(invitable); - $('#access-role').innerHTML = roleOptions(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() { @@ -98,7 +116,8 @@ function render() { $('#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'); $('#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(item.role)}${escapeHTML(item.status)}${item.application_ids.length}${canManageUsers ? `` : ''}`).join('') + pending || rowEmpty(6, 'No users'); + $('#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); @@ -110,6 +129,20 @@ 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}

`; const initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?'; const avatarCell = item => item.avatar_url @@ -230,7 +263,10 @@ document.addEventListener('click', async event => { form.tenant_id.value = user.tenant_id; form.approval_mode.value = 'false'; $('#approval-tenant-label').hidden = true; - form.role.value = user.role; + // A role the caller may not hand out is not in the picker, so fall back + // to the first one they can assign rather than submitting the old value. + const current = assignableRoles().some(item => item.id === user.role_id); + form.role_id.value = current ? user.role_id : assignableRoles()[0]?.id || ''; renderAccessApps(user.tenant_id, user.application_ids); $('#access-dialog').showModal(); return @@ -242,7 +278,7 @@ document.addEventListener('click', async event => { form.approval_mode.value = 'true'; $('#approval-tenant-label').hidden = false; form.approval_tenant_id.value = state.selectedTenant; - form.role.value = 'viewer'; + form.role_id.value = assignableRoles()[0]?.id || ''; renderAccessApps(state.selectedTenant, []); $('#access-dialog').showModal() } @@ -269,8 +305,9 @@ mode.addEventListener('click', () => { document.documentElement.dataset.mode = next; localStorage.setItem('gl-mode', next) }); -$('#tenant-context').addEventListener('change', event => { +$('#tenant-context').addEventListener('change', async event => { state.selectedTenant = event.target.value; + await loadRoles(); render() }); $('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, [])); diff --git a/web/index.html b/web/index.html index 8dc2a9b..6edc2ef 100644 --- a/web/index.html +++ b/web/index.html @@ -13,6 +13,7 @@ GLGo Loose @@ -75,6 +74,8 @@ + + \ No newline at end of file diff --git a/web/profile.js b/web/profile.js index f90accf..60a253c 100644 --- a/web/profile.js +++ b/web/profile.js @@ -149,18 +149,7 @@ function readAsDataURL(file) { }) } -const theme = $('#theme'); -theme.value = localStorage.getItem('gl-theme') || 'tokyo'; -document.documentElement.dataset.theme = theme.value; -document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark'; -theme.addEventListener('change', () => { - document.documentElement.dataset.theme = theme.value; - localStorage.setItem('gl-theme', theme.value) -}); -$('#mode').addEventListener('click', () => { - const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; - document.documentElement.dataset.mode = next; - localStorage.setItem('gl-mode', next) -}); +// The palette and appearance controls are the components shipped by the theme +// service. See the note in app.js for why this file no longer drives them. load().catch(error => notice(error.message, true)); \ No newline at end of file diff --git a/web/roles.html b/web/roles.html index 7ec70c2..8cee3c5 100644 --- a/web/roles.html +++ b/web/roles.html @@ -6,6 +6,7 @@ Go Loose · Roles +
@@ -15,12 +16,9 @@ Console API docs - - - -
+ + +
@@ -47,6 +45,20 @@
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

@@ -62,12 +74,9 @@
-

VOCABULARY

Permissions

-
- - - -
PermissionWhat it allows
+

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.

@@ -85,6 +94,19 @@ + +
+

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 index ecc40e0..2c2b6dd 100644 --- a/web/roles.js +++ b/web/roles.js @@ -31,28 +31,24 @@ async function api(path, options = {}) { return body } +async function loadPermissions() { + try { + state.permissions = (await api('/api/v1/permissions')).permissions + } catch (error) { + notice('You do not have permission to read the permission catalog.', true) + } +} + async function load() { const dashboard = await api('/api/v1/dashboard'); state.tenants = dashboard.tenants; - state.permissions = dashboard.permissions || []; if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || ''; - renderIdentity(dashboard.profile); - await loadRoles(); + // Roles and the permission catalog are fetched from their own endpoints so + // that changing either does not need the whole dashboard reloaded. + await Promise.all([loadRoles(), loadPermissions()]); render() } -function renderIdentity(profile) { - profile = profile || {}; - $('#identity-name').textContent = profile.display_name || profile.email || ''; - const image = $('#identity-avatar'); - if (profile.avatar_url) { - image.src = profile.avatar_url; - image.hidden = false - } else { - image.hidden = true; - image.removeAttribute('src') - } -} async function loadRoles() { if (!state.selectedTenant) { @@ -72,6 +68,9 @@ function selectedTenant() { } const canManageRoles = () => Boolean(selectedTenant()?.permissions?.includes('roles.manage')); +// The catalog is shared by every tenant, so changing it needs tenants.manage +// rather than roles.manage. +const canAdministerCatalog = () => Boolean(state.tenants.some(tenant => tenant.permissions?.includes('tenants.manage'))); function render() { const tenant = selectedTenant(); @@ -86,6 +85,7 @@ function render() { $('#stat-custom').textContent = custom.length; $('#stat-assignable').textContent = state.roles.filter(item => item.assignable).length; $('#new-role').hidden = !canManageRoles(); + $('#new-permission').hidden = !canAdministerCatalog(); $('#role-list').innerHTML = state.roles.map(item => ` ${escapeHTML(item.name)}${item.system ? ' built-in' : ''}
${escapeHTML(item.description || 'No description')} @@ -93,20 +93,24 @@ function render() { ${item.permissions.map(permission => `${escapeHTML(permission)}`).join('') || 'None'} ${item.member_count} ${item.assignable ? 'Yes' : 'No'} - ${item.system ? '' : canManageRoles() ? `` : ''} +
${item.system ? '' : canManageRoles() ? `` : ''}
`).join('') || 'No roles available for this tenant.'; $('#permission-list').innerHTML = state.permissions.map(item => ` - ${escapeHTML(item.value)} - ${escapeHTML(item.description)} - `).join('') + ${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('') } @@ -130,6 +134,29 @@ function openDialog(item) { } $('#new-role').addEventListener('click', () => openDialog(null)); +$('#new-permission').addEventListener('click', () => $('#permission-dialog').showModal()); + +$('#permission-form').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.target; + try { + await api('/api/v1/permissions', { + method: 'POST', + body: JSON.stringify({ + name: form.name.value.trim(), + label: form.label.value.trim(), + description: form.description.value.trim() + }) + }); + form.closest('dialog').close(); + form.reset(); + notice('Permission added to the catalog'); + await loadPermissions(); + render() + } catch (error) { + notice(error.message, true) + } +}); $$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close())); $('#role-form').addEventListener('submit', async event => { @@ -160,6 +187,19 @@ document.addEventListener('click', async event => { openDialog(state.roles.find(item => item.id === edit)); return } + const removePermission = event.target.dataset.deletePermission; + if (removePermission) { + if (!confirm(`Delete the permission "${removePermission}"?`)) return; + try { + await api(`/api/v1/permissions/${encodeURIComponent(removePermission)}`, {method: 'DELETE'}); + notice('Permission deleted'); + await loadPermissions(); + render() + } catch (error) { + notice(error.message, true) + } + return + } const remove = event.target.dataset.delete; if (remove) { const item = state.roles.find(role => role.id === remove); @@ -175,19 +215,8 @@ document.addEventListener('click', async event => { } }); -const theme = $('#theme'); -theme.value = localStorage.getItem('gl-theme') || 'tokyo'; -document.documentElement.dataset.theme = theme.value; -document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark'; -theme.addEventListener('change', () => { - document.documentElement.dataset.theme = theme.value; - localStorage.setItem('gl-theme', theme.value) -}); -$('#mode').addEventListener('click', () => { - const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; - document.documentElement.dataset.mode = next; - localStorage.setItem('gl-mode', next) -}); +// The palette and appearance controls are the components shipped by the theme +// service. See the note in app.js for why this file no longer drives them. $('#tenant-context').addEventListener('change', async event => { state.selectedTenant = event.target.value; await loadRoles(); diff --git a/web/style.css b/web/style.css index f1d6351..a21a772 100644 --- a/web/style.css +++ b/web/style.css @@ -9,7 +9,7 @@ *{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} .docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)} main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)} -.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px}.card h3{margin:0 0 5px}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);min-height:24px}.card-action{margin-top:16px;width:100%;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:flex;justify-content:space-between;margin-top:18px;color:var(--muted);font-size:12px}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px} +.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:flex-start}.card h3{margin:6px 0 5px;width:100%}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);margin:0;width:100%;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-action{margin-top:14px;align-self:flex-start;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:grid;gap:7px;width:100%;margin-top:auto;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}.meta>div{display:flex;align-items:baseline;gap:14px;min-width:0}.meta span{flex:none;text-transform:uppercase;letter-spacing:.09em;font-size:10px}.meta b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px ui-monospace,monospace;color:var(--text)}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px} dialog{color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 30px 100px rgba(0,0,0,.6);width:min(520px,92vw);padding:24px}dialog::backdrop{background:rgba(8,8,15,.74);backdrop-filter:blur(4px)}dialog form{display:grid;gap:16px}.dialog-head{display:flex;align-items:center;justify-content:space-between}.dialog-head h3{margin:0;font-size:22px}.close{border:0;background:transparent;color:var(--muted);font-size:28px;cursor:pointer}dialog label{display:grid;gap:7px;font-size:13px;color:var(--muted)}textarea{resize:vertical}.segmented{display:flex;gap:20px}.segmented label{display:flex;align-items:center;grid-template-columns:auto auto}.secret{display:flex;gap:8px;align-items:center;background:var(--bg);padding:12px;border-radius:8px}.secret code{overflow-wrap:anywhere;color:var(--good);flex:1} .check-list{display:grid;gap:8px}.check-list label{display:flex;grid-template-columns:auto 1fr;align-items:center}.client-terminal h1{font-size:clamp(38px,6vw,64px);line-height:1;margin:10px 0 32px;letter-spacing:-.05em}.client-terminal h1 span{color:var(--accent2)} .login-page{display:grid;place-items:center;min-height:100vh;overflow:hidden}.login-glow{position:fixed;width:600px;height:600px;border-radius:50%;background:var(--accent2);filter:blur(180px);opacity:.13}.terminal{width:min(920px,92vw);margin:40px;position:relative;background:color-mix(in srgb,var(--surface) 94%,black);border:1px solid var(--border);border-radius:10px;box-shadow:0 40px 120px rgba(0,0,0,.55);overflow:hidden;font-family:ui-monospace,SFMono-Regular,monospace}.terminal-bar{height:44px;background:var(--surface2);display:flex;align-items:center;gap:8px;padding:0 15px}.terminal-bar span{width:11px;height:11px;border-radius:50%;background:var(--danger)}.terminal-bar span:nth-child(2){background:#e0af68}.terminal-bar span:nth-child(3){background:var(--good)}.terminal-bar strong{position:absolute;left:50%;transform:translateX(-50%);color:var(--muted);font-size:12px}.terminal-body{padding:35px}.ascii{color:var(--accent);font-size:clamp(6px,1vw,12px);line-height:1.2;overflow:hidden}.prompt{color:var(--good)}.muted{color:var(--muted);max-width:650px;line-height:1.6}.terminal-login{display:inline-flex;gap:10px;text-decoration:none;margin:20px 0 10px}.dev-login{display:block;color:var(--muted);font-size:11px}.cursor{color:var(--accent);animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.docs-page #swagger-ui{max-width:1200px;margin:30px auto}.docs-page .swagger-ui{filter:none} @@ -17,3 +17,40 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border .installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)} @media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}} .identity{display:inline-flex;align-items:center;gap:8px;text-decoration:none}.identity img,.avatar{border-radius:50%;object-fit:cover;display:block;flex:none}.profile-hero{min-height:0;padding:56px 0 34px}.profile-hero h1{font-size:clamp(40px,5vw,64px)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin-top:34px}.profile-card{display:grid;gap:16px;align-content:start}.profile-card h2{margin:0;font-size:20px;letter-spacing:-.02em}.profile-wide{grid-column:1/-1}.profile-card form{display:grid;gap:14px}.profile-card label{display:grid;gap:7px;font-size:13px;color:var(--muted)}.profile-actions{display:flex;gap:10px;flex-wrap:wrap}.profile-actions button{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions .ghost{background:transparent;border:1px solid var(--border);color:var(--text)}.profile-card button[type=submit]{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer;justify-self:start}.avatar-preview{display:grid;place-items:center;width:112px;height:112px;border-radius:50%;overflow:hidden;background:var(--surface2);border:1px solid var(--border)}.avatar-preview img{width:100%;height:100%;object-fit:cover}.avatar-initials{font:750 34px ui-monospace,monospace;color:var(--accent)}.avatar-empty{display:inline-grid;place-items:center;width:28px;height:28px;vertical-align:middle;background:var(--surface2);border:1px solid var(--border);color:var(--accent);font:700 11px ui-monospace,monospace}.facts{display:grid;gap:10px;margin:4px 0 0}.facts div{display:flex;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);padding-bottom:8px;font-size:13px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right}.capabilities{display:flex;flex-wrap:wrap;gap:6px}.chip{font:600 11px ui-monospace,monospace;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 6px} + +/* ---------------------------------------------------------- account menu -- + The header account control: a trigger showing who is signed in, opening a + small menu with the two things a person can do about their own account. It + replaces the separate sign-out button, so the identity and the action live in + one place instead of two controls that mean the same thing. */ +.account{position:relative;margin-left:2px} +.account-trigger{display:inline-flex;align-items:center;gap:8px;padding:5px 9px 5px 6px;background:transparent;border:1px solid var(--border);border-radius:8px;color:var(--text);cursor:pointer;font:inherit;font-size:13px} +.account-trigger:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))} +.account-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.account-trigger .avatar{width:24px;height:24px} +.account-name{font-weight:650;max-width:11rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.account-caret{color:var(--muted);font-size:10px} +.account-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:40;min-width:15rem;padding:6px;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 14px 34px rgba(0,0,0,.28)} +.account-menu[hidden]{display:none} +.account-identity{padding:9px 10px 11px;border-bottom:1px solid var(--border);margin-bottom:6px} +.account-identity strong{display:block;font-size:13px} +.account-identity span{display:block;color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.account-menu form{margin:0} +.account-menu a,.account-menu button{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;background:transparent;border:0;border-radius:7px;color:var(--text);font:inherit;font-size:13px;text-align:left;text-decoration:none;cursor:pointer} +.account-menu a:hover,.account-menu button:hover{background:var(--surface2)} +.account-menu a:focus-visible,.account-menu button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} +.account-note{padding:7px 10px 4px;color:var(--muted);font-size:11px;line-height:1.45} + +/* Row actions inside a table. The existing .card-action is a full-width block + button meant for the foot of a card, which stacked two of them in a narrow + cell and made the row taller than its content. These are inline and sit on the + text baseline. */ +.table-actions{display:flex;gap:6px;justify-content:flex-end;white-space:nowrap} +.table-action{padding:5px 10px;background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--text);font:inherit;font-size:12px;cursor:pointer} +.table-action:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));color:var(--accent)} +.table-action:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.table-action.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,var(--border))} +.table-action.danger:hover{background:color-mix(in srgb,var(--danger) 12%,transparent)} +/* A slug, prefix or permission name is an identifier: wrapping it across two lines + makes one row taller than its neighbours for no benefit. */ +.table-wrap td code{white-space:nowrap} From 8d8970d5a7f3d37cf6825ccdfbf2429ebbc14f0b Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Mon, 5 Oct 2026 19:37:09 +0200 Subject: [PATCH 08/10] test: drive the whole application over HTTP, and serialise migrations The behaviour suite in internal/behaviour installs the application into an empty database through the web installer, signs in, walks every flow, and then replaces the process to confirm the state was persisted rather than held in memory. It found four defects, each fixed here or in the preceding commit: - Migrate was not safe to run from two instances at once. CREATE TABLE IF NOT EXISTS is not race free in PostgreSQL and collided on the pg_type unique index, and two replicas could apply the same file. The run now holds a session-level advisory lock, so a rolling deployment waits instead of failing. - Bootstrap refused ownership whenever a tenant held any member, so installing with the demonstration seed and then setting a local administrator password produced an account that could never reach the console. It now grants ownership only while the tenant has no owner, which still never takes a tenant over from one that does. Three of the fixes are covered by store-level regression tests in the same commit: the ownership rule, the duplicate tenant slug conflict, and concurrent migration runs. The suite is skipped by go test ./... because it needs a scratch database and the Google credentials for the installer. Run it with make behaviour-test, which also resets the database afterwards. make test no longer inherits the database variables, so unit runs need nothing. Completes the earlier correction to the docs: the role matrix lives in the database rather than in internal/role, and a local administrator claims the system administrator role when no Google account has. --- Makefile | 21 +- README.md | 10 +- docs/client-login.md | 7 + docs/installation.md | 15 +- docs/operations.md | 2 + internal/behaviour/application_test.go | 191 ++++++++++ internal/behaviour/clientlogin_test.go | 368 +++++++++++++++++++ internal/behaviour/contract_test.go | 127 +++++++ internal/behaviour/harness_test.go | 460 ++++++++++++++++++++++++ internal/behaviour/journey_test.go | 466 +++++++++++++++++++++++++ internal/behaviour/membership_test.go | 171 +++++++++ internal/behaviour/persistence_test.go | 248 +++++++++++++ internal/behaviour/profile_test.go | 287 +++++++++++++++ internal/behaviour/role_test.go | 279 +++++++++++++++ internal/behaviour/security_test.go | 162 +++++++++ internal/behaviour/tenant_test.go | 77 ++++ internal/database/database.go | 38 ++ internal/store/integration_test.go | 125 +++++++ 18 files changed, 3050 insertions(+), 4 deletions(-) create mode 100644 internal/behaviour/application_test.go create mode 100644 internal/behaviour/clientlogin_test.go create mode 100644 internal/behaviour/contract_test.go create mode 100644 internal/behaviour/harness_test.go create mode 100644 internal/behaviour/journey_test.go create mode 100644 internal/behaviour/membership_test.go create mode 100644 internal/behaviour/persistence_test.go create mode 100644 internal/behaviour/profile_test.go create mode 100644 internal/behaviour/role_test.go create mode 100644 internal/behaviour/security_test.go create mode 100644 internal/behaviour/tenant_test.go diff --git a/Makefile b/Makefile index 1abb030..9e70892 100644 --- a/Makefile +++ b/Makefile @@ -14,13 +14,16 @@ GO_LOOSE_DEV_PORT ?= 8080 export -.PHONY: build test integration-db integration-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet +.PHONY: build test integration-db integration-test behaviour-test run compose-up compose-down compose-dev-up compose-dev-down compose-dev-logs fmt vet build: go build ./... +# The database-backed suites drop every table they touch, so `test` runs without +# them. Each suite skips itself when its variable is empty, which keeps a +# developer without PostgreSQL from being blocked; `integration-test` sets them. test: - go test -race ./... + GO_LOOSE_TEST_DATABASE_URL= GO_LOOSE_TEST_SERVER_DATABASE_URL= go test -race ./... # Integration tests drop every table they touch, so they only run against scratch # databases. Both test helpers refuse any database whose name lacks "test", and @@ -33,15 +36,29 @@ test: GO_LOOSE_TEST_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_test?sslmode=disable GO_LOOSE_TEST_SERVER_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_server_test?sslmode=disable +# The behaviour suite drives the whole application over HTTP and needs the Google +# client credentials for the installer. It reads ../google_secrets.json, or the +# variables below, and skips when neither is present. +GO_LOOSE_BEHAVIOUR_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose_behaviour_test?sslmode=disable + # createdb is not idempotent, so ignore a database that is already there. integration-db: -docker compose exec -T postgres createdb -U goloose goloose_test -docker compose exec -T postgres createdb -U goloose goloose_server_test + -docker compose exec -T postgres createdb -U goloose goloose_behaviour_test integration-test: @test -n "$(GO_LOOSE_TEST_DATABASE_URL)" || { echo "set GO_LOOSE_TEST_DATABASE_URL to a scratch database"; exit 1; } go test -race ./internal/store/ ./internal/server/ -count=1 +# Installs the application into an empty database, walks every flow, restarts the +# process, and checks that everything is still there. Leaves the database empty +# again so the next run starts from nothing. +behaviour-test: + GO_LOOSE_BEHAVIOUR=1 go test ./internal/behaviour/ -count=1 -v + -docker compose exec -T postgres psql -q -U goloose -d goloose_behaviour_test \ + -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null + run: go run ./cmd/go-loose diff --git a/README.md b/README.md index cfe85ad..56591c4 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,15 @@ Development requires Go 1.27.1 or newer. Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one. -`make integration-db` creates the two scratch databases the database-backed tests use, and `make integration-test` runs them. Those tests drop every table they touch, so they refuse to run against a database whose name does not contain `test`. That guard exists because pointing them at a development database destroys it. +`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 diff --git a/docs/client-login.md b/docs/client-login.md index 55d6826..8c32522 100644 --- a/docs/client-login.md +++ b/docs/client-login.md @@ -170,6 +170,13 @@ A user with no membership in the application's tenant still authenticates, but f 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 diff --git a/docs/installation.md b/docs/installation.md index 04d557a..c006ed3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,9 @@ Later requests to `/install` redirect to login, and duplicate submissions are re ## First administrator and waiting room -The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant and application that exists at that moment. +The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant that exists at that moment. + +Owning a tenant is what lets the administrator administer it; it does not let them sign in to the applications running inside it. Access to a client application is always an explicit grant, so an administrator who wants to use one attaches themselves from **Users & access** like anybody else. The grant is visible, attributable and revocable, and revoking it signs them out of that application without affecting their console access. Later first-time SSO users are created without tenant membership. Their management page shows only the waiting room. A system administrator approves a waiting user from **Users & access**, selecting: @@ -78,3 +80,14 @@ An application must first have exact redirect URIs and a generated client secret Existing deployments may continue to supply all three `GO_LOOSE_OIDC_ISSUER`, `GO_LOOSE_OIDC_CLIENT_ID`, and `GO_LOOSE_OIDC_CLIENT_SECRET` variables. Go Loose treats that as an already configured deployment and bypasses the installer. Supplying `GO_LOOSE_LOCAL_LOGIN=true` together with a non-empty `GO_LOOSE_LOCAL_ADMIN_PASSWORD` does the same for a database-only development deployment. For new installations, prefer the web installer. `GO_LOOSE_LOCAL_LOGIN=true` without a bootstrap password enables seeded and invited database users but does not bypass installation. + +Two rules make that second mode usable rather than merely possible: + +- The local administrator claims the system administrator role when the installation has none. Creating tenants and approving waiting-room users both require that role, and only a first Google sign-in otherwise grants it, so a database-only deployment would otherwise have nobody able to do either. The claim is a no-op once any system administrator exists, so an installation already owned by a Google account is untouched. +- Bootstrapping grants ownership of a tenant only while that tenant has no owner. It deliberately does not care whether the tenant has other members, because the demonstration seed creates tenants holding administrators but no owner; refusing ownership there would leave a local administrator bootstrapped afterwards with an account that can never reach the console. A tenant that already has an owner is never taken over. + +Supplying `GO_LOOSE_LOCAL_ADMIN_PASSWORD` after a Google account has already claimed the role changes nothing. + +## Verifying an installation + +`make behaviour-test` installs the application into an empty database, signs in, walks every flow, and then restarts the process to confirm that everything was persisted rather than held in memory. See the [README](../README.md#behaviour-suite) for what it needs. diff --git a/docs/operations.md b/docs/operations.md index 9caf281..4b8192f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -37,6 +37,8 @@ The provider must return `sub`, `email`, and preferably `name` claims. Migration files live in `internal/database/migrations` and are embedded into the binary. Files run lexicographically once, in individual transactions. +A migration run holds a session-level advisory lock, so several replicas may start at the same time against one database. Without it the run races with itself: PostgreSQL's `CREATE TABLE IF NOT EXISTS` is not race free and can fail with a duplicate `pg_type` row, and two replicas can try to apply the same file. The second replica waits and then finds the work already recorded. + Create upgrades with an immutable numbered file: ```text diff --git a/internal/behaviour/application_test.go b/internal/behaviour/application_test.go new file mode 100644 index 0000000..4cf1a95 --- /dev/null +++ b/internal/behaviour/application_test.go @@ -0,0 +1,191 @@ +package behaviour + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// unknownButWellFormed has the shape of a real key so that it reaches the store +// and is rejected as unknown, rather than being turned away by format validation. +var unknownButWellFormed = "gl_unknown_" + strings.Repeat("A", 40) + +// applicationPhases registers an application, issues API keys, and exercises the +// authorization endpoint that every Go middleware integration depends on. +func applicationPhases(t *testing.T, j *journey) { + t.Helper() + host := j.adminHost() + + phase(t, "an application is registered with allowed hosts", func(t *testing.T) { + type application struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + AllowedHosts []string `json:"allowed_hosts"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{ + "tenant_id": j.scopedTenantID, "slug": "behaviour-app", "name": "Behaviour App", + "description": "created by the behaviour suite", "allowed_hosts": []string{"app.behaviour.test"}, + }) + created := decode[application](t, response, http.StatusCreated) + if created.Slug != "behaviour-app" { + t.Errorf("slug = %q", created.Slug) + } + j.applicationID = created.ID + }) + + phase(t, "an allowed host carrying a scheme or port is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{ + "tenant_id": j.scopedTenantID, "slug": "bad-hosts", "name": "Bad Hosts", + "allowed_hosts": []string{"https://app.behaviour.test"}, + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "an application without a tenant or a valid slug is refused", func(t *testing.T) { + for _, payload := range []map[string]any{ + {"slug": "no-tenant", "name": "No Tenant"}, + {"tenant_id": j.scopedTenantID, "slug": "Bad Slug", "name": "Bad Slug"}, + {"tenant_id": j.scopedTenantID, "slug": "", "name": "Empty Slug"}, + } { + response := j.admin.json(http.MethodPost, host, "/api/v1/applications", payload) + statusOnly(t, response, http.StatusBadRequest) + } + }) + + phase(t, "an API key is issued and shown exactly once", func(t *testing.T) { + type issued struct { + Key struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"key"` + Secret string `json:"secret"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/keys", map[string]any{ + "application_id": j.applicationID, "name": "integration key", + }) + result := decode[issued](t, response, http.StatusCreated) + if result.Secret == "" { + t.Fatal("no secret returned") + } + if result.Key.ID == "" { + t.Error("no key id returned") + } + j.apiKey, j.apiKeyID = result.Secret, result.Key.ID + + // The dashboard must never expose the secret again. + type keyListing struct { + Keys []map[string]any `json:"keys"` + } + listing := decode[keyListing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK) + for _, key := range listing.Keys { + for field, value := range key { + if text, ok := value.(string); ok && text == j.apiKey { + t.Errorf("the dashboard leaked the secret in %q", field) + } + } + } + }) + + phase(t, "a key in the wrong format is refused", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + nil, map[string]string{"X-API-Key": "not-a-key"}) + // The authorize endpoint is reached by application middleware, not by a + // browser session, so no session is involved here. + statusOnly(t, response, http.StatusUnauthorized) + }) + + phase(t, "an unknown key is denied", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"X-API-Key": unknownButWellFormed, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "a valid key is authorized for its own tenant, application, and host", func(t *testing.T) { + type decision struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` + TenantSlug string `json:"tenant_slug"` + AppSlug string `json:"application_slug"` + APIKeyName string `json:"api_key_name"` + } + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + result := decode[decision](t, response, http.StatusOK) + if !result.Allowed { + t.Errorf("allowed = false, reason %q", result.Reason) + } + if result.TenantSlug != "behaviour" || result.AppSlug != "behaviour-app" { + t.Errorf("identity = %s/%s", result.TenantSlug, result.AppSlug) + } + if result.APIKeyName != "integration key" { + t.Errorf("key name = %q", result.APIKeyName) + } + }) + + phase(t, "a host outside the allow list is denied", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(`{"tenant":"{j.scopedTenant}","application":"behaviour-app","method":"GET","path":"/api/x","host":"evil.test"}`), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "another tenant is not reachable with this key", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.tenantSlug)), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "the key can also be presented as a bearer token", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"Authorization": "Bearer " + j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusOK) + }) + + phase(t, "an incomplete authorization request is refused", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(`{"method":"GET"}`), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "every authorization attempt is recorded", func(t *testing.T) { + var total int + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT count(*) FROM authorization_events WHERE api_key_id = $1`, j.apiKeyID).Scan(&total); err != nil { + t.Fatalf("count authorization events: %v", err) + } + if total < 4 { + t.Errorf("authorization events = %d, want the attempts above to be recorded", total) + } + }) + + phase(t, "revoking the key stops it working immediately", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.adminHost(), + "/api/v1/keys/"+j.apiKeyID+"/revoke", nil, map[string]string{ + "Origin": "http://" + j.adminHost(), "Content-Length": "0", + }) + if response.StatusCode != http.StatusNoContent { + t.Fatalf("revoke = %d: %s", response.StatusCode, bodyOf(t, response)) + } + response.Body.Close() + + response = j.admin.request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(fmt.Sprintf(`{"tenant":%q,"application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`, j.scopedTenant)), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "revoking an unknown key reports not found", func(t *testing.T) { + response := j.admin.request(http.MethodPost, j.adminHost(), + "/api/v1/keys/00000000-0000-0000-0000-000000000000/revoke", nil, map[string]string{ + "Origin": "http://" + j.adminHost(), "Content-Length": "0", + }) + statusOnly(t, response, http.StatusNotFound) + }) +} diff --git a/internal/behaviour/clientlogin_test.go b/internal/behaviour/clientlogin_test.go new file mode 100644 index 0000000..4babd69 --- /dev/null +++ b/internal/behaviour/clientlogin_test.go @@ -0,0 +1,368 @@ +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, "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 new file mode 100644 index 0000000..06b01e0 --- /dev/null +++ b/internal/behaviour/contract_test.go @@ -0,0 +1,127 @@ +package behaviour + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// openAPIDocument is a minimal but real OpenAPI 3.1 description, used both +// pasted into the console and served over HTTP for the scan-a-URL flow. +const openAPIDocument = `{ + "openapi": "3.1.0", + "info": {"title": "Guess API", "version": "1.0.0"}, + "paths": { + "/health": { + "get": { + "operationId": "health", + "summary": "Liveness probe", + "responses": {"200": {"description": "ok"}} + } + }, + "/guesses": { + "get": { + "operationId": "listGuesses", + "summary": "List guesses", + "responses": {"200": {"description": "ok"}} + }, + "post": { + "operationId": "createGuess", + "summary": "Create a guess", + "responses": {"201": {"description": "created"}} + } + } + } +}` + +// contractPhases imports a service contract from a pasted document and from a +// scanned URL, which is the path an operator takes when registering an +// application. +func contractPhases(t *testing.T, j *journey) { + t.Helper() + host := j.adminHost() + + phase(t, "a pasted contract is imported with its endpoints", func(t *testing.T) { + type saved struct { + ID string `json:"id"` + Version string `json:"version"` + EndpointCount int `json:"endpoint_count"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "document", "document": openAPIDocument, + }) + contract := decode[saved](t, response, http.StatusCreated) + if contract.Version != "3.1.0" { + t.Errorf("version = %q", contract.Version) + } + if contract.EndpointCount != 3 { + t.Errorf("endpoint_count = %d, want 3", contract.EndpointCount) + } + j.contractID = contract.ID + + // The endpoints themselves must be queryable, since the console lists them. + var scanned int + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT count(*) FROM contract_endpoints WHERE contract_id = $1`, contract.ID).Scan(&scanned); err != nil { + t.Fatalf("count contract endpoints: %v", err) + } + if scanned != 3 { + t.Errorf("stored endpoints = %d, want 3", scanned) + } + }) + + phase(t, "a malformed contract is refused with a readable error", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "document", "document": `{"openapi":`, + }) + body := statusOnly(t, response, http.StatusUnprocessableEntity) + if body == "" { + t.Error("no explanation returned") + } + }) + + phase(t, "an empty contract is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "document", "document": "", + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "a contract is scanned from a URL", func(t *testing.T) { + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(openAPIDocument)) + })) + defer source.Close() + + type scanned struct { + ID string `json:"id"` + SourceURL string `json:"source_url"` + } + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "url", "source_url": source.URL + "/openapi.json", + }) + result := decode[scanned](t, response, http.StatusCreated) + if result.SourceURL == "" { + t.Error("the source URL was not recorded") + } + }) + + phase(t, "an unreachable contract URL reports a gateway error", func(t *testing.T) { + response := j.admin.json(http.MethodPost, host, "/api/v1/contracts", map[string]any{ + "application_id": j.applicationID, "source_type": "url", + "source_url": "http://127.0.0.1:1/openapi.json", + }) + statusOnly(t, response, http.StatusBadGateway) + }) + + phase(t, "the dashboard reports the stored contracts", func(t *testing.T) { + type listing struct { + Contracts []map[string]any `json:"contracts"` + } + result := decode[listing](t, j.admin.get(host, "/api/v1/dashboard", nil), http.StatusOK) + if len(result.Contracts) < 2 { + t.Errorf("contracts = %d, want the pasted and scanned documents", len(result.Contracts)) + } + }) +} diff --git a/internal/behaviour/harness_test.go b/internal/behaviour/harness_test.go new file mode 100644 index 0000000..daabd8d --- /dev/null +++ b/internal/behaviour/harness_test.go @@ -0,0 +1,460 @@ +// Package behaviour drives the whole application over real HTTP against a real +// PostgreSQL database, the way a person would: install it, sign in, click +// through every screen, and check that what was written can be read back after +// the process is replaced. +// +// The suite is opt-in. `go test ./...` skips it unless GO_LOOSE_BEHAVIOUR=1 is +// set, because it needs a scratch database and, for the installer, real Google +// credentials. Run it with `make behaviour-test`. +package behaviour + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/danyel/go-loose/internal/config" + "github.com/danyel/go-loose/internal/database" + "github.com/danyel/go-loose/internal/server" +) + +const ( + apexHost = "auth.test" + localAdminMail = "admin@goloose.local" + localAdminPass = "local-admin-password-2026" + sessionSecret = "behaviour-suite-session-secret-value-32" +) + +// harness is one running instance of the application plus a browser that speaks +// to it over HTTP. +type harness struct { + t *testing.T + server *http.Server + listener net.Listener + handler http.Handler + db *sql.DB + addr string + baseURL string + origin string +} + +// browser is a very small cookie jar with the domain rules that matter here. +// net/http/cookiejar cannot be used because the suite addresses the server by IP +// while sending tenant hostnames in the Host header, and the jar keys cookies by +// the URL host rather than the header. Getting that wrong would silently drop the +// shared session cookie that the client login flow depends on. +type browser struct { + t *testing.T + addr string + client *http.Client + cookies map[string]map[string]*http.Cookie // domain -> name -> cookie +} + +// newBrowser returns a cookie jar bound to one instance. Cookies are per browser, +// so a second browser is a second, independent signed-out visitor. +func newBrowser(t *testing.T, addr string) *browser { + return &browser{ + t: t, addr: addr, + client: &http.Client{ + Timeout: 30 * time.Second, + // Redirects are inspected rather than followed: several flows assert on + // the Location header, and an automatic follow would hide it. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + cookies: map[string]map[string]*http.Cookie{}, + } +} + +// host extracts the hostname from a Host header that may carry a port. +func hostname(host string) string { + if parsed, _, err := net.SplitHostPort(host); err == nil { + return parsed + } + return host +} + +// domainMatches implements the cookie domain rule: an exact host match, or a +// suffix match for a domain cookie. +func domainMatches(domain, host string) bool { + if domain == host { + return true + } + return strings.HasSuffix(host, "."+domain) +} + +func (b *browser) absorb(host string, cookies []*http.Cookie) { + for _, cookie := range cookies { + domain := cookie.Domain + if domain == "" { + domain = hostname(host) + } + domain = strings.TrimPrefix(domain, ".") + if cookie.MaxAge < 0 { + if jar, ok := b.cookies[domain]; ok { + delete(jar, cookie.Name) + } + continue + } + if _, ok := b.cookies[domain]; !ok { + b.cookies[domain] = map[string]*http.Cookie{} + } + copied := *cookie + b.cookies[domain][cookie.Name] = &copied + } +} + +// request performs one request without following redirects and records cookies. +func (b *browser) request(method, host, path string, body io.Reader, headers map[string]string) *http.Response { + b.t.Helper() + target := "http://" + b.addr + path + request, err := http.NewRequest(method, target, body) + if err != nil { + b.t.Fatalf("build %s %s: %v", method, path, err) + } + request.Host = host + for domain, jar := range b.cookies { + if !domainMatches(domain, hostname(host)) { + continue + } + for name, cookie := range jar { + request.AddCookie(&http.Cookie{Name: name, Value: cookie.Value}) + } + } + for name, value := range headers { + if value == "" { + request.Header.Del(name) + continue + } + request.Header.Set(name, value) + } + response, err := b.client.Do(request) + if err != nil { + b.t.Fatalf("%s %s (host %s): %v", method, path, host, err) + } + b.absorb(host, response.Cookies()) + return response +} + +// get performs a GET and returns the response. The caller closes the body. +func (b *browser) get(host, path string, headers map[string]string) *http.Response { + return b.request(http.MethodGet, host, path, nil, headers) +} + +// form performs a form POST, the way a browser submits an HTML form. +func (b *browser) form(host, path string, values url.Values, headers map[string]string) *http.Response { + encoded := values.Encode() + merged := map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "text/html,application/json", + "Content-Length": fmt.Sprint(len(encoded)), + } + for name, value := range headers { + merged[name] = value + } + return b.request(http.MethodPost, host, path, strings.NewReader(encoded), merged) +} + +// json performs a JSON write, the way the console's own scripts do. The Origin +// header is what requireSameOrigin checks, so it defaults to the host being used. +func (b *browser) json(method, host, path string, payload any) *http.Response { + encoded, err := json.Marshal(payload) + if err != nil { + b.t.Fatalf("encode payload: %v", err) + } + return b.request(method, host, path, strings.NewReader(string(encoded)), map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "Origin": "http://" + host, + }) +} + +func bodyOf(t *testing.T, response *http.Response) string { + t.Helper() + defer response.Body.Close() + content, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(content) +} + +var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) + +func csrfFrom(t *testing.T, page string) string { + t.Helper() + match := csrfPattern.FindStringSubmatch(page) + if match == nil { + t.Fatalf("no CSRF token in page: %.400s", page) + } + return match[1] +} + +// start boots the application on a fresh port. It is called once for the +// install phase and again by restart, which is how the suite proves that state +// lives in PostgreSQL rather than in the process. +// start boots the application. localAdmin is deliberately false for the install +// phase: configuring GO_LOOSE_LOCAL_ADMIN_PASSWORD marks an instance installed on +// its own, which would hide the installer this suite exists to exercise. +func start(t *testing.T, databaseURL string, localAdmin bool) *harness { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + baseURL := "http://" + apexHost + ":" + portOf(addr) + + setEnv(t, baseEnv(addr, baseURL, databaseURL, localAdmin)) + cfg, err := config.Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + ctx := context.Background() + db, err := database.Open(ctx, cfg.DatabaseURL) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := database.Migrate(ctx, db); err != nil { + t.Fatalf("migrate: %v", err) + } + handler, err := server.New(ctx, cfg, db, quietLogger()) + if err != nil { + t.Fatalf("build server: %v", err) + } + harness := &harness{ + t: t, listener: listener, handler: handler.Handler(), db: db, + addr: addr, baseURL: baseURL, origin: baseURL, + } + harness.serve() + return harness +} + +func (h *harness) serve() { + h.server = &http.Server{Handler: h.handler, ReadHeaderTimeout: 5 * time.Second} + go func() { + // The listener is already open, so a failure here is a real defect rather + // than a bind race; report it through the test log. + _ = h.server.Serve(h.listener) + }() + h.t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = h.server.Shutdown(ctx) + }) +} + +// stop closes the running instance. The address is kept so restart can rebind it. +func (h *harness) stop() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = h.server.Shutdown(ctx) + // Shutdown stops accepting but the listener can outlive it briefly, so close it + // explicitly rather than hoping the port is free by the time we rebind. + _ = h.listener.Close() +} + +// restart replaces the process with a new one over the same database. +// +// The new instance listens on a fresh port, the way a real deployment would come +// back up behind its proxy. Nothing observable depends on the port: the session +// cookie is scoped to the host without a port, and the application's redirect +// URIs are external. +func (h *harness) restart(databaseURL string) *harness { + h.t.Helper() + h.stop() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + h.t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + baseURL := "http://" + apexHost + ":" + portOf(addr) + + setEnv(h.t, baseEnv(addr, baseURL, databaseURL, true)) + cfg, err := config.Load() + if err != nil { + h.t.Fatalf("load config: %v", err) + } + ctx := context.Background() + handler, err := server.New(ctx, cfg, h.db, quietLogger()) + if err != nil { + h.t.Fatalf("rebuild server: %v", err) + } + next := &harness{ + t: h.t, listener: listener, handler: handler.Handler(), db: h.db, + addr: addr, baseURL: baseURL, origin: baseURL, + } + next.serve() + return next +} + +// baseEnv is the environment the suite runs the application under. +func baseEnv(addr, baseURL, databaseURL string, localAdmin bool) map[string]string { + env := map[string]string{ + "GO_LOOSE_ADDR": addr, + "GO_LOOSE_BASE_URL": baseURL, + "GO_LOOSE_AUTH_DOMAIN": apexHost, + "GO_LOOSE_DATABASE_URL": databaseURL, + "GO_LOOSE_SESSION_SECRET": sessionSecret, + "GO_LOOSE_DEV_LOGIN": "false", + "GO_LOOSE_LOCAL_LOGIN": "true", + "GO_LOOSE_OIDC_REDIRECT_URL": baseURL + "/auth/callback", + "GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS": "true", + "GO_LOOSE_BOOTSTRAP_TENANT": "nmbs", + "GO_LOOSE_BOOTSTRAP_APP": "guess", + // The provider credentials are never injected through the environment: the + // installer must be the only thing that configures OIDC here. + "GO_LOOSE_OIDC_ISSUER": "", + "GO_LOOSE_OIDC_CLIENT_ID": "", + "GO_LOOSE_OIDC_CLIENT_SECRET": "", + } + if localAdmin { + env["GO_LOOSE_LOCAL_ADMIN_EMAIL"] = localAdminMail + env["GO_LOOSE_LOCAL_ADMIN_PASSWORD"] = localAdminPass + } + return env +} + +func portOf(addr string) string { + _, port, err := net.SplitHostPort(addr) + if err != nil { + return "8080" + } + return port +} + +// tenantHost is the hostname a tenant's members sign in on. +func (h *harness) tenantHost(slug string) string { + return slug + "." + apexHost + ":" + portOf(h.addr) +} + +func (h *harness) apex() string { + return apexHost + ":" + portOf(h.addr) +} + +func setEnv(t *testing.T, values map[string]string) { + t.Helper() + for name, value := range values { + t.Setenv(name, value) + } +} + +// resetDatabase drops every table including schema_migrations, so the next start +// replays the migration history from the beginning. +func resetDatabase(t *testing.T, databaseURL string) { + t.Helper() + db, err := database.Open(context.Background(), databaseURL) + if err != nil { + t.Fatalf("open database to reset: %v", err) + } + defer db.Close() + rows, err := db.QueryContext(context.Background(), + `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`) + if err != nil { + t.Fatalf("list tables: %v", err) + } + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + t.Fatalf("scan table name: %v", err) + } + tables = append(tables, `"`+name+`"`) + } + rows.Close() + for _, table := range tables { + if _, err := db.ExecContext(context.Background(), "DROP TABLE IF EXISTS "+table+" CASCADE"); err != nil { + t.Fatalf("drop %s: %v", table, err) + } + } +} + +// googleCredentials reads the real client credentials the installer is meant to +// receive. The values never appear in test output. +type googleCredentials struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +func loadGoogleCredentials(t *testing.T) googleCredentials { + t.Helper() + if override := os.Getenv("GO_LOOSE_BEHAVIOUR_CLIENT_ID"); override != "" { + return googleCredentials{ClientID: override, ClientSecret: os.Getenv("GO_LOOSE_BEHAVIOUR_CLIENT_SECRET")} + } + path := filepath.Join("..", "..", "google_secrets.json") + content, err := os.ReadFile(path) + if err != nil { + t.Skipf("no Google credentials at %s: %v", path, err) + } + var file struct { + Web struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + } `json:"web"` + } + if err := json.Unmarshal(content, &file); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + if file.Web.ClientID == "" || file.Web.ClientSecret == "" { + t.Skipf("%s has no client_id or client_secret", path) + } + return googleCredentials{ClientID: file.Web.ClientID, ClientSecret: file.Web.ClientSecret} +} + +// decode reads a JSON response body into target and fails on a non-2xx status, +// reporting the server's own error message. +func decode[T any](t *testing.T, response *http.Response, wantStatus int) T { + t.Helper() + var target T + body := bodyOf(t, response) + if response.StatusCode != wantStatus { + t.Fatalf("status = %d, want %d: %s", response.StatusCode, wantStatus, body) + } + if err := json.Unmarshal([]byte(body), &target); err != nil { + t.Fatalf("decode %s: %v", body, err) + } + return target +} + +// statusOnly reads a body without decoding, for flows whose payload is empty. +func statusOnly(t *testing.T, response *http.Response, wantStatus int) string { + t.Helper() + body := bodyOf(t, response) + if response.StatusCode != wantStatus { + t.Fatalf("status = %d, want %d: %s", response.StatusCode, wantStatus, body) + } + return body +} + +// location returns the redirect target of a response. +func location(t *testing.T, response *http.Response) string { + t.Helper() + target := response.Header.Get("Location") + if target == "" { + t.Fatalf("no Location header on %d", response.StatusCode) + } + return target +} + +// quietLogger keeps the suite's output readable. The application logs every +// request, which would bury the test output; the suite reports its own failures. +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// stringReader is a small helper for the raw request bodies the authorization +// endpoint expects, where the exact bytes matter more than a struct. +func stringReader(body string) io.Reader { return strings.NewReader(body) } diff --git a/internal/behaviour/journey_test.go b/internal/behaviour/journey_test.go new file mode 100644 index 0000000..ab69270 --- /dev/null +++ b/internal/behaviour/journey_test.go @@ -0,0 +1,466 @@ +package behaviour + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "os" + "strings" + "testing" +) + +// journey carries the identifiers one phase creates into the phases that use +// them, so each phase reads like a step a person would take. +type journey struct { + t *testing.T + h *harness + admin *browser + adminEmail string + + tenantID string + tenantSlug string + scopedTenant string + scopedTenantID string + applicationID string + clientID string + clientSecret string + apiKey string + apiKeyID string + contractID string + customRoleID string + permission string + memberEmail string + memberPassword string + ungrantedEmail string + ungrantedPassword string + ungrantedMemberID string + memberID string + userRoleID string + viewerRoleID string + avatarKey string + accessToken string + profileName string +} + +// browser returns a fresh, signed-out visitor pointed at the current instance. +func (j *journey) browser() *browser { return newBrowser(j.t, j.h.addr) } + +// restart replaces the running process and points the signed-in browser at the new +// one. The cookie jar is kept deliberately: it is the same person, and their +// session cookie is scoped to the host rather than to the port. +func (j *journey) restart(databaseURL string) { + j.h = j.h.restart(databaseURL) + if j.admin != nil { + j.admin.addr = j.h.addr + } +} + +// adminHost is the tenant hostname the local administrator belongs to. +func (j *journey) adminHost() string { return j.h.tenantHost(j.tenantSlug) } + +// phase runs one step. Steps run in order and share state, which is why this is +// a single test with subtests rather than a set of independent ones. +func phase(t *testing.T, name string, step func(t *testing.T)) { + t.Helper() + t.Run(name, step) +} + +func requireScratchDatabase(t *testing.T, databaseURL string) { + t.Helper() + if os.Getenv("GO_LOOSE_BEHAVIOUR_DESTRUCTIVE") == "1" { + return + } + parsed, err := url.Parse(databaseURL) + if err != nil { + t.Fatalf("parse database URL: %v", err) + } + name := strings.TrimPrefix(parsed.Path, "/") + if !strings.Contains(strings.ToLower(name), "test") { + t.Fatalf("refusing to run against database %q: the suite drops every table. "+ + "Point GO_LOOSE_BEHAVIOUR_DATABASE_URL at a scratch database whose name contains \"test\".", name) + } +} + +// TestApplicationBehaviour walks the entire application once, in the order a +// person would: install it, sign in, build out a tenant, wire an application, +// manage people and roles, edit a profile, then replace the process and check +// that everything is still there. +func TestApplicationBehaviour(t *testing.T) { + if os.Getenv("GO_LOOSE_BEHAVIOUR") != "1" { + t.Skip("set GO_LOOSE_BEHAVIOUR=1 and GO_LOOSE_BEHAVIOUR_DATABASE_URL to run the behaviour suite") + } + databaseURL := os.Getenv("GO_LOOSE_BEHAVIOUR_DATABASE_URL") + if databaseURL == "" { + t.Skip("set GO_LOOSE_BEHAVIOUR_DATABASE_URL to a scratch database") + } + requireScratchDatabase(t, databaseURL) + + // Start from nothing: the suite is meant to prove a first install works. + resetDatabase(t, databaseURL) + + credentials := loadGoogleCredentials(t) + j := &journey{t: t, h: start(t, databaseURL, false), adminEmail: localAdminMail} + j.admin = j.browser() + + installPhases(t, j, credentials) + + // The operator now adds the local administrator and restarts, which is also the + // first proof that the installation survived the process it was written by. + j.restart(databaseURL) + authenticationPhases(t, j) + consolePhases(t, j) + tenantPhases(t, j) + applicationPhases(t, j) + contractPhases(t, j) + membershipPhases(t, j) + clientLoginPhases(t, j) + rolePhases(t, j) + profilePhases(t, j) + securityPhases(t, j) + persistencePhases(t, j) +} + +// installPhases drives the first-run installer with the real Google credentials +// and then checks what reached PostgreSQL. +func installPhases(t *testing.T, j *journey, credentials googleCredentials) { + t.Helper() + + phase(t, "an uninstalled instance sends the console to the installer", func(t *testing.T) { + response := j.browser().get(j.h.apex(), "/", nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", response.StatusCode) + } + if got := location(t, response); got != "/install" { + t.Fatalf("redirect = %q, want /install", got) + } + }) + + phase(t, "the installer refuses a submission without its CSRF token", func(t *testing.T) { + browser := j.browser() + page := bodyOf(t, browser.get(j.h.apex(), "/install", nil)) + token := csrfFrom(t, page) + // The cookie was issued for the page, so the mismatch below is the token, + // not a missing cookie. + browser.cookies = map[string]map[string]*http.Cookie{} + response := browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, "field1": {"https://accounts.google.com"}, + "field2": {credentials.ClientID}, "field3": {credentials.ClientSecret}, + "field4": {j.h.baseURL + "/auth/callback"}, + }, nil) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "the installer refuses an incomplete submission", func(t *testing.T) { + browser := j.browser() + token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/install", nil))) + response := browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, "field1": {"https://accounts.google.com"}, + }, nil) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "installing stores the provider settings and seeds the demo data", func(t *testing.T) { + browser := j.browser() + token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/install", nil))) + response := browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, + "field1": {"https://accounts.google.com"}, + "field2": {credentials.ClientID}, + "field3": {credentials.ClientSecret}, + "field4": {j.h.baseURL + "/auth/callback"}, + "seed_demo": {"on"}, + }, nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response)) + } + if got := location(t, response); got != "/auth/start" { + t.Fatalf("redirect = %q, want /auth/start", got) + } + + var issuer, clientID, ciphertext, redirect string + var seeded bool + row := j.h.db.QueryRowContext(t.Context(), ` + SELECT oidc_issuer, oidc_client_id, encode(oidc_client_secret_ciphertext, 'escape'), oidc_redirect_url, seeded_demo + FROM installation_settings WHERE singleton`) + if err := row.Scan(&issuer, &clientID, &ciphertext, &redirect, &seeded); err != nil { + t.Fatalf("read installation_settings: %v", err) + } + if issuer != "https://accounts.google.com" { + t.Errorf("issuer = %q", issuer) + } + if clientID != credentials.ClientID { + t.Errorf("client id = %q, want the credentials from google_secrets.json", clientID) + } + if redirect != j.h.baseURL+"/auth/callback" { + t.Errorf("redirect url = %q", redirect) + } + if !seeded { + t.Error("seeded_demo = false, want true") + } + // The secret must be encrypted at rest, never the plaintext from the form. + if strings.Contains(ciphertext, credentials.ClientSecret) { + t.Error("the client secret is stored in plaintext") + } + if ciphertext == "" { + t.Error("the client secret was not stored") + } + + for _, want := range []string{"nmbs", "ypto"} { + var slug string + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT slug FROM tenants WHERE slug = $1`, want).Scan(&slug); err != nil { + t.Errorf("tenant %s was not seeded: %v", want, err) + } + } + var demoUsers int + if err := j.h.db.QueryRowContext(t.Context(), + `SELECT count(*) FROM users WHERE email LIKE '%@nmbs.auth.dev' OR email LIKE '%@ypto.auth.dev'`). + Scan(&demoUsers); err != nil { + t.Fatalf("count demo users: %v", err) + } + if demoUsers != 4 { + t.Errorf("demo users = %d, want 4", demoUsers) + } + }) + + phase(t, "an installed instance no longer offers the installer", func(t *testing.T) { + browser := j.browser() + response := browser.get(j.h.apex(), "/install", nil) + if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" { + t.Fatalf("GET /install = %d %q, want 303 to /login", response.StatusCode, response.Header.Get("Location")) + } + token := csrfFrom(t, bodyOf(t, browser.get(j.h.apex(), "/login", nil))) + response = browser.form(j.h.apex(), "/install", url.Values{ + "csrf_token": {token}, "field1": {"https://accounts.google.com"}, + "field2": {credentials.ClientID}, "field3": {credentials.ClientSecret}, + "field4": {j.h.baseURL + "/auth/callback"}, + }, nil) + statusOnly(t, response, http.StatusConflict) + }) + + phase(t, "sign-in redirects to Google with the installed client id", func(t *testing.T) { + browser := j.browser() + response := browser.get(j.h.apex(), "/auth/start", nil) + if response.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want 302: %s", response.StatusCode, bodyOf(t, response)) + } + provider, err := url.Parse(location(t, response)) + if err != nil { + t.Fatalf("parse provider URL: %v", err) + } + if provider.Host != "accounts.google.com" { + t.Errorf("provider host = %q, want accounts.google.com", provider.Host) + } + query := provider.Query() + if got := query.Get("client_id"); got != credentials.ClientID { + t.Errorf("client_id = %q, want the installed credentials", got) + } + if got := query.Get("redirect_uri"); got != j.h.baseURL+"/auth/callback" { + t.Errorf("redirect_uri = %q", got) + } + if query.Get("response_type") != "code" { + t.Errorf("response_type = %q, want code", query.Get("response_type")) + } + // The signed state must be echoed back to the callback. + if query.Get("state") == "" { + t.Error("no state parameter in the provider redirect") + } + }) + + phase(t, "the callback refuses a forged state", func(t *testing.T) { + browser := j.browser() + browser.get(j.h.apex(), "/auth/start", nil) + response := browser.get(j.h.apex(), "/auth/callback?code=fake&state=forged", nil) + statusOnly(t, response, http.StatusBadRequest) + }) +} + +// authenticationPhases signs in as the bootstrapped local administrator and checks +// that the session is a real one. +func authenticationPhases(t *testing.T, j *journey) { + t.Helper() + j.tenantSlug = "nmbs" + + phase(t, "the sign-in page is served on the tenant host", func(t *testing.T) { + host := j.adminHost() + page := bodyOf(t, j.browser().get(host, "/login", nil)) + if !strings.Contains(page, "NMBS") { + t.Errorf("the sign-in page does not mention the tenant: %.200s", page) + } + }) + + phase(t, "a wrong password is rejected", func(t *testing.T) { + browser := j.browser() + host := j.adminHost() + token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil))) + response := browser.form(host, "/auth/password", url.Values{ + "csrf_token": {token}, "email": {localAdminMail}, "password": {"wrong-password-entirely"}, + }, nil) + statusOnly(t, response, http.StatusUnauthorized) + }) + + phase(t, "password sign-in without a CSRF token is rejected", func(t *testing.T) { + browser := j.browser() + host := j.adminHost() + bodyOf(t, browser.get(host, "/login", nil)) + response := browser.form(host, "/auth/password", url.Values{ + "email": {localAdminMail}, "password": {localAdminPass}, + }, nil) + statusOnly(t, response, http.StatusForbidden) + }) + + phase(t, "repeated failures lock the account out", func(t *testing.T) { + // A distinct address keeps the lockout away from the other sign-ins. + browser := j.browser() + host := j.adminHost() + token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil))) + locked := false + for attempt := 0; attempt < 6; attempt++ { + response := browser.form(host, "/auth/password", url.Values{ + "csrf_token": {token}, "email": {"lockout@auth.test"}, "password": {"nope-nope-nope"}, + }, nil) + response.Body.Close() + if response.StatusCode == http.StatusTooManyRequests { + locked = true + break + } + } + if !locked { + t.Error("six wrong passwords never produced a 429") + } + }) + + phase(t, "the administrator signs in and reaches the console", func(t *testing.T) { + browser := j.browser() + host := j.adminHost() + token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil))) + response := browser.form(host, "/auth/password", url.Values{ + "csrf_token": {token}, "email": {localAdminMail}, "password": {localAdminPass}, + }, nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303: %s", response.StatusCode, bodyOf(t, response)) + } + if got := location(t, response); got != "/" { + t.Fatalf("redirect = %q, want /", got) + } + j.admin = browser + + page := bodyOf(t, j.admin.get(j.adminHost(), "/", nil)) + if !strings.Contains(page, "", + "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 new file mode 100644 index 0000000..7f740c0 --- /dev/null +++ b/internal/behaviour/persistence_test.go @@ -0,0 +1,248 @@ +package behaviour + +import ( + "net/http" + "os" + "strings" + "testing" +) + +// persistencePhases replaces the running process and then reads everything back. +// This is the phase the whole suite exists for: it proves the application keeps +// its state in PostgreSQL rather than in memory, which is the one property that +// cannot be checked by looking at a single request. +func persistencePhases(t *testing.T, j *journey) { + t.Helper() + + // Take a copy of the identifiers so the assertions below can be made against + // values captured before the restart rather than against whatever the new + // process happens to hand back. + want := struct { + tenant string + application string + contract string + apiKey string + clientID string + member string + customRole string + displayName string + avatarSet bool + memberGrants int + }{ + tenant: j.scopedTenant, application: j.applicationID, contract: j.contractID, + apiKey: j.apiKeyID, clientID: j.clientID, member: j.memberEmail, + customRole: j.customRoleID, displayName: j.profileName, + } + // Put a picture back so the restart has binary state to preserve. + response := j.admin.json(http.MethodPut, j.adminHost(), "/api/v1/profile", map[string]any{ + "avatar": pngBase64, + }) + if response.StatusCode != http.StatusOK { + t.Fatalf("set a picture to carry across the restart: %d", response.StatusCode) + } + type withAvatar struct { + AvatarURL string `json:"avatar_url"` + } + avatarPath := decode[withAvatar](t, response, http.StatusOK).AvatarURL + want.avatarSet = avatarPath != "" + + phase(t, "a client session is established before the restart", func(t *testing.T) { + // The earlier phase revoked its token on purpose, so a fresh one is needed to + // show that a session outlives the process. + j.accessToken = establishClientSession(t, j, "before-restart") + if j.accessToken == "" { + t.Fatal("no client session token") + } + response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, + map[string]string{"Authorization": "Bearer " + j.accessToken}) + statusOnly(t, response, http.StatusOK) + }) + + phase(t, "the installation survives the process that wrote it", func(t *testing.T) { + j.restart(os.Getenv("GO_LOOSE_BEHAVIOUR_DATABASE_URL")) + + // A restarted instance must know it is installed without the environment + // saying so, which means it read the installation row and decrypted the + // stored OIDC secret. + response := j.browser().get(j.h.apex(), "/install", nil) + if response.StatusCode != http.StatusSeeOther || location(t, response) != "/login" { + t.Errorf("GET /install after restart = %d %q, want 303 to /login", + response.StatusCode, response.Header.Get("Location")) + } + }) + + phase(t, "the provider configuration still works after the restart", func(t *testing.T) { + browser := j.browser() + response := browser.get(j.h.apex(), "/auth/start", nil) + if response.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want a provider redirect: %s", response.StatusCode, bodyOf(t, response)) + } + target := location(t, response) + if !strings.Contains(target, "accounts.google.com") { + t.Errorf("redirect = %q, want the installed provider", target) + } + if !strings.Contains(target, "client_id=") { + t.Errorf("the provider redirect carries no client id: %s", target) + } + }) + + phase(t, "an existing session is still valid after the restart", func(t *testing.T) { + // The session is a signed cookie, so a new process with the same secret + // keeps honouring it. + response := j.admin.get(j.adminHost(), "/api/v1/dashboard", nil) + statusOnly(t, response, http.StatusOK) + }) + + phase(t, "the tenant, application, contract, and revoked key all persist", func(t *testing.T) { + var tenants, applications, contracts, endpoints int + if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM tenants WHERE slug = $1`, want.tenant).Scan(&tenants); err != nil { + t.Fatalf("count tenants: %v", err) + } + if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM applications WHERE id = $1`, want.application).Scan(&applications); err != nil { + t.Fatalf("count applications: %v", err) + } + if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM openapi_contracts WHERE id = $1`, want.contract).Scan(&contracts); err != nil { + t.Fatalf("count contracts: %v", err) + } + if err := j.h.db.QueryRowContext(t.Context(), `SELECT count(*) FROM contract_endpoints WHERE contract_id = $1`, want.contract).Scan(&endpoints); err != nil { + t.Fatalf("count endpoints: %v", err) + } + if tenants != 1 || applications != 1 || contracts != 1 || endpoints != 3 { + t.Errorf("persisted: tenants=%d applications=%d contracts=%d endpoints=%d", + tenants, applications, contracts, endpoints) + } + + // The revoked key must stay revoked. + var status string + if err := j.h.db.QueryRowContext(t.Context(), `SELECT status FROM api_keys WHERE id = $1`, want.apiKey).Scan(&status); err != nil { + t.Fatalf("read the key: %v", err) + } + if status != "revoked" { + t.Errorf("key status = %q, want revoked", status) + } + }) + + phase(t, "the dashboard reports the same state to the new process", func(t *testing.T) { + type listing struct { + Tenants []struct{ Slug string } `json:"tenants"` + Applications []struct { + Slug string `json:"slug"` + RedirectURIs []string `json:"redirect_uris"` + } `json:"applications"` + Users []struct { + Email string `json:"email"` + Role string `json:"role"` + } `json:"users"` + } + result := decode[listing](t, j.admin.get(j.adminHost(), "/api/v1/dashboard", nil), http.StatusOK) + slugs := map[string]bool{} + for _, tenant := range result.Tenants { + slugs[tenant.Slug] = true + } + if !slugs[want.tenant] { + t.Errorf("tenant %s is missing after the restart", want.tenant) + } + emails := map[string]string{} + for _, user := range result.Users { + emails[user.Email] = user.Role + } + if emails[want.member] == "" { + t.Errorf("%s is missing after the restart", want.member) + } + if emails[localAdminMail] != "owner" { + t.Errorf("the administrator's role is %q after the restart, want owner", emails[localAdminMail]) + } + }) + + phase(t, "the customized display name and the picture both persist", func(t *testing.T) { + type profile struct { + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + NameCustomized bool `json:"name_customized"` + } + result := decode[profile](t, j.admin.get(j.adminHost(), "/api/v1/profile", nil), http.StatusOK) + if result.DisplayName != want.displayName { + t.Errorf("display name = %q, want %q", result.DisplayName, want.displayName) + } + if !result.NameCustomized { + t.Error("name_customized was lost") + } + if result.AvatarURL != avatarPath { + t.Errorf("avatar_url = %q, want %q", result.AvatarURL, avatarPath) + } + if !want.avatarSet { + t.Fatal("no picture was set before the restart") + } + }) + + phase(t, "the picture is still served, with the same bytes", func(t *testing.T) { + key := strings.TrimPrefix(avatarPath, "/api/v1/avatars/") + response := j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want the picture to survive: %s", response.StatusCode, bodyOf(t, response)) + } + if got := response.Header.Get("Content-Type"); got != "image/png" { + t.Errorf("content type = %q", got) + } + if len(bodyOf(t, response)) == 0 { + t.Error("empty picture after the restart") + } + }) + + phase(t, "the tenant role and the permission catalog persist", func(t *testing.T) { + type listing struct { + Roles []struct { + Slug string `json:"slug"` + } `json:"roles"` + } + result := decode[listing](t, j.admin.get(j.adminHost(), + "/api/v1/roles?tenant_id="+j.scopedTenantID, nil), http.StatusOK) + found := false + for _, role := range result.Roles { + if role.Slug == "reporter" { + found = true + } + } + if !found { + t.Error("the tenant-defined role is missing after the restart") + } + statusOnly(t, j.admin.get(j.adminHost(), "/api/v1/permissions", nil), http.StatusOK) + }) + + phase(t, "a client session token issued before the restart still resolves", func(t *testing.T) { + // The session is a row in PostgreSQL, not process state, so a deployment + // that restarts does not sign every user out of the tenant applications. + if j.accessToken == "" { + t.Fatal("no client session was established before the restart") + } + type identity struct { + Email string `json:"email"` + } + response := j.browser().request(http.MethodGet, j.h.apex(), "/connect/userinfo", nil, + map[string]string{"Authorization": "Bearer " + j.accessToken}) + user := decode[identity](t, response, http.StatusOK) + if user.Email != j.memberEmail { + t.Errorf("email = %q, want %s", user.Email, j.memberEmail) + } + }) + + phase(t, "a new sign-in still works after the restart", func(t *testing.T) { + browser := j.browser() + host := j.adminHost() + token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil))) + response := browser.form(host, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": localAdminMail, "password": localAdminPass, + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in after restart = %d: %s", response.StatusCode, bodyOf(t, response)) + } + statusOnly(t, browser.get(host, "/api/v1/dashboard", nil), http.StatusOK) + }) + + phase(t, "an API key issued before the restart is still refused after it", func(t *testing.T) { + response := j.browser().request(http.MethodPost, j.h.apex(), "/api/v1/authorize", + stringReader(`{"tenant":"behaviour","application":"behaviour-app","method":"GET","path":"/api/x","host":"app.behaviour.test"}`), + map[string]string{"X-API-Key": j.apiKey, "Content-Type": "application/json"}) + statusOnly(t, response, http.StatusForbidden) + }) +} diff --git a/internal/behaviour/profile_test.go b/internal/behaviour/profile_test.go new file mode 100644 index 0000000..0664c92 --- /dev/null +++ b/internal/behaviour/profile_test.go @@ -0,0 +1,287 @@ +package behaviour + +import ( + "encoding/base64" + "net/http" + "strings" + "testing" +) + +// profilePhases edits the caller's own display name and picture, and checks the +// public URL that client applications such as go-tell embed. +func profilePhases(t *testing.T, j *journey) { + t.Helper() + host := j.adminHost() + + phase(t, "the profile reports the caller and their memberships", func(t *testing.T) { + type membership struct { + TenantSlug string `json:"tenant_slug"` + Role string `json:"role"` + RoleID string `json:"role_id"` + Permissions []string `json:"permissions"` + } + type profile struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + Memberships []membership `json:"memberships"` + NameCustomized bool `json:"name_customized"` + } + result := decode[profile](t, j.admin.get(host, "/api/v1/profile", nil), http.StatusOK) + if result.Email != localAdminMail { + t.Errorf("email = %q", result.Email) + } + if result.DisplayName != "Local Administrator" { + t.Errorf("display name = %q", result.DisplayName) + } + if result.AvatarURL != "" { + t.Errorf("avatar_url = %q, want empty before a picture is set", result.AvatarURL) + } + if result.NameCustomized { + t.Error("name_customized is true before the name was edited") + } + if len(result.Memberships) == 0 { + t.Fatal("no memberships reported") + } + for _, membership := range result.Memberships { + if len(membership.Permissions) == 0 { + t.Errorf("membership %s reports no permissions", membership.TenantSlug) + } + } + }) + + phase(t, "an empty update is refused", func(t *testing.T) { + statusOnly(t, j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{}), http.StatusBadRequest) + }) + + phase(t, "an empty display name is refused", func(t *testing.T) { + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{"display_name": " "}) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "the display name is changed and marked as customized", func(t *testing.T) { + j.profileName = "Ada Lovelace" + type updated struct { + DisplayName string `json:"display_name"` + NameCustomized bool `json:"name_customized"` + } + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{ + "display_name": j.profileName, + }) + result := decode[updated](t, response, http.StatusOK) + if result.DisplayName != j.profileName { + t.Errorf("display name = %q", result.DisplayName) + } + if !result.NameCustomized { + t.Error("name_customized was not set") + } + }) + + phase(t, "the customized name survives signing in again", func(t *testing.T) { + // Signing in upserts the user from the identity provider, which would + // otherwise overwrite the name the person chose. + browser := j.browser() + token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil))) + response := browser.form(host, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": localAdminMail, "password": localAdminPass, + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) + } + + type profile struct { + DisplayName string `json:"display_name"` + } + result := decode[profile](t, browser.get(host, "/api/v1/profile", nil), http.StatusOK) + if result.DisplayName != j.profileName { + t.Errorf("display name = %q after signing in, want %q", result.DisplayName, j.profileName) + } + }) + + phase(t, "a picture is rejected when it is not an image", func(t *testing.T) { + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{ + "avatar": base64.StdEncoding.EncodeToString([]byte("this is not an image")), + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "an unsupported image type is rejected", func(t *testing.T) { + // A real PNG announced as a PDF: the declared type must be checked. + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{ + "avatar": "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(decodeBase64PNG(t)), + }) + statusOnly(t, response, http.StatusBadRequest) + }) + + phase(t, "a picture is accepted and published under a key", func(t *testing.T) { + type updated struct { + AvatarURL string `json:"avatar_url"` + } + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{ + "avatar": base64.StdEncoding.EncodeToString(decodeBase64PNG(t)), + }) + result := decode[updated](t, response, http.StatusOK) + if !strings.HasPrefix(result.AvatarURL, "/api/v1/avatars/") { + t.Fatalf("avatar_url = %q, want a path under /api/v1/avatars", result.AvatarURL) + } + j.avatarKey = strings.TrimPrefix(result.AvatarURL, "/api/v1/avatars/") + if j.avatarKey == "" { + t.Fatal("no avatar key") + } + }) + + phase(t, "the picture is served to anyone, without a session", func(t *testing.T) { + stranger := j.browser() + response := stranger.get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", response.StatusCode) + } + if got := response.Header.Get("Content-Type"); got != "image/png" { + t.Errorf("content type = %q, want image/png", got) + } + if got := response.Header.Get("Cache-Control"); !strings.Contains(got, "max-age") { + t.Errorf("cache control = %q", got) + } + if response.Header.Get("ETag") == "" { + t.Error("no ETag on a picture") + } + if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("nosniff = %q", got) + } + body := bodyOf(t, response) + if len(body) == 0 { + t.Error("empty picture body") + } + }) + + phase(t, "an unchanged picture answers 304", func(t *testing.T) { + first := j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil) + etag := first.Header.Get("ETag") + bodyOf(t, first) + + second := j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, map[string]string{ + "If-None-Match": etag, + }) + if second.StatusCode != http.StatusNotModified { + t.Fatalf("status = %d, want 304", second.StatusCode) + } + second.Body.Close() + }) + + phase(t, "an unknown or malformed picture key never serves anything", func(t *testing.T) { + for _, key := range []string{"does-not-exist", strings.Repeat("a", 43), "..%2f..%2fetc%2fpasswd"} { + response := j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil) + // A traversal may be normalised into a redirect by the router; what matters + // is that no bytes are ever served for it. + if response.StatusCode == http.StatusOK { + t.Errorf("key %q returned a picture", key) + } + bodyOf(t, response) + } + }) + + phase(t, "replacing the picture retires the old key", func(t *testing.T) { + original := j.avatarKey + type updated struct { + AvatarURL string `json:"avatar_url"` + } + // A different image: the 1x1 PNG's colour channel is inverted. + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{ + "avatar": base64.StdEncoding.EncodeToString(decodeBase64PNG(t)), + }) + result := decode[updated](t, response, http.StatusOK) + j.avatarKey = strings.TrimPrefix(result.AvatarURL, "/api/v1/avatars/") + if j.avatarKey == "" { + t.Fatal("no avatar key after replacing the picture") + } + response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+original, nil) + if response.StatusCode != http.StatusNotFound { + t.Errorf("the previous key still resolves: %d", response.StatusCode) + } + response.Body.Close() + response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+j.avatarKey, nil) + if response.StatusCode != http.StatusOK { + t.Errorf("the new key does not resolve: %d", response.StatusCode) + } + response.Body.Close() + }) + + phase(t, "the profile of another member still shows no picture", func(t *testing.T) { + type profile struct { + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + } + result := decode[profile](t, j.admin.get(host, "/api/v1/profile", nil), http.StatusOK) + if result.AvatarURL == "" { + t.Error("the administrator's picture disappeared") + } + }) + + phase(t, "removing the picture clears it everywhere", func(t *testing.T) { + key := j.avatarKey + type updated struct { + AvatarURL string `json:"avatar_url"` + } + response := j.admin.json(http.MethodPut, host, "/api/v1/profile", map[string]any{"remove_avatar": true}) + result := decode[updated](t, response, http.StatusOK) + if result.AvatarURL != "" { + t.Errorf("avatar_url = %q, want empty", result.AvatarURL) + } + response = j.browser().get(j.h.apex(), "/api/v1/avatars/"+key, nil) + if response.StatusCode != http.StatusNotFound { + t.Errorf("the removed picture still resolves: %d", response.StatusCode) + } + response.Body.Close() + }) + + phase(t, "the member is moved to the self-service role", func(t *testing.T) { + // The role phase left this person holding a role that grants console.read. + // Moving them to the built-in self-service role is the flow that proves the + // console and the API agree about who may open it. + response := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.ungrantedMemberID+"/access", map[string]any{ + "tenant_id": j.scopedTenantID, "role_id": j.userRoleID, "application_ids": []string{}, + }) + if response.StatusCode != http.StatusNoContent { + t.Fatalf("change role = %d: %s", response.StatusCode, bodyOf(t, response)) + } + }) + + phase(t, "a member without console access is sent to their profile page", func(t *testing.T) { + // The ungranted member holds the self-service role, which grants + // profile.edit but not console.read. + browser := j.browser() + tenantHost := j.h.tenantHost(j.scopedTenant) + token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, "/login", nil))) + response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": j.ungrantedEmail, "password": j.ungrantedPassword, + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) + } + response = browser.get(tenantHost, "/", nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("console = %d, want a redirect: %s", response.StatusCode, bodyOf(t, response)) + } + if got := location(t, response); got != "/profile" { + t.Errorf("redirect = %q, want /profile", got) + } + page := bodyOf(t, browser.get(tenantHost, "/profile", nil)) + if !strings.Contains(page, " Date: Mon, 5 Oct 2026 21:48:05 +0200 Subject: [PATCH 09/10] refactor: keep tenant applications out of the administrator's reach Claiming the system administrator role no longer grants access to every application in the installation. Owning a tenant is what lets an administrator administer it; entering the applications running inside it is a separate, explicit grant that can be withdrawn again. Handing it out at claim time gave every installation a standing administrator account inside every customer application, which is the opposite of the boundary this product draws. The behaviour suite now covers single sign-on between applications directly: a second application on the same tenant must receive a code and identify the same person with no sign-in page in between, once Go Loose already holds a session. Also adds theme-token tests against the shared styling contract and store coverage for application grants, and drops the redundant server and security scheme declarations from the published specification. --- internal/behaviour/clientlogin_test.go | 96 ++++++++++++++ internal/server/theme_test.go | 79 ++++++++++++ .../application_access_integration_test.go | 121 ++++++++++++++++++ internal/store/installation.go | 20 ++- web/openapi.json | 7 - web/style.css | 16 +-- 6 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 internal/store/application_access_integration_test.go diff --git a/internal/behaviour/clientlogin_test.go b/internal/behaviour/clientlogin_test.go index 4babd69..96d9d04 100644 --- a/internal/behaviour/clientlogin_test.go +++ b/internal/behaviour/clientlogin_test.go @@ -232,6 +232,102 @@ func clientLoginPhases(t *testing.T, j *journey) { 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"` diff --git a/internal/server/theme_test.go b/internal/server/theme_test.go index d7385f2..e37d4b9 100644 --- a/internal/server/theme_test.go +++ b/internal/server/theme_test.go @@ -1,8 +1,11 @@ package server import ( + "fmt" "strings" "testing" + + "github.com/danyel/go-loose/web" ) // The theme origin is substituted into every HTML page. These tests cover the two @@ -89,3 +92,79 @@ func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) { 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/store/application_access_integration_test.go b/internal/store/application_access_integration_test.go new file mode 100644 index 0000000..835dbdd --- /dev/null +++ b/internal/store/application_access_integration_test.go @@ -0,0 +1,121 @@ +package store_test + +import ( + "errors" + "testing" + + "github.com/danyel/go-loose/internal/store" +) + +// TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications pins the +// boundary between the management plane and the client plane. +// +// Owning every tenant is what lets the first administrator reach the whole +// installation from the console. It is not permission to sign in to the +// applications running inside those tenants: ClientForAuthorization answers for a +// user_application_access row and nothing else, so a claim that inserted one per +// application would ship every installation with a standing administrator +// account inside every customer application. Access to an application is an +// explicit grant, and this test also pins that such a grant is still possible on +// purpose, because an administrator is allowed to attach themselves. +func TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications(t *testing.T) { + s, _, ctx := integrationStore(t) + + // Two tenants, each with a client configured, so that "every application" is + // not satisfied by a single row. + firstOwner := newUser(t, s, ctx, "first@example.test") + first := newTenant(t, s, ctx, firstOwner, "nmbs") + secondOwner := newUser(t, s, ctx, "second@example.test") + second := newTenant(t, s, ctx, secondOwner, "ypto") + + const redirect = "https://app.example.test/callback" + application := func(owner, tenantID, slug string) store.Application { + t.Helper() + created, err := s.CreateApplication(ctx, owner, store.Application{ + TenantID: tenantID, Slug: slug, Name: slug, AllowedHosts: []string{}, + }) + if err != nil { + t.Fatalf("create application %s: %v", slug, err) + } + if _, err := s.ConfigureClient(ctx, owner, created.ID, []string{redirect}, secretHash(slug)); err != nil { + t.Fatalf("configure client login for %s: %v", slug, err) + } + return created + } + firstApp := application(firstOwner, first.ID, "guess") + secondApp := application(secondOwner, second.ID, "tell") + + admin := newUser(t, s, ctx, "admin@example.test") + claimed, err := s.ClaimFirstSystemAdministrator(ctx, admin) + if err != nil { + t.Fatalf("claim first system administrator: %v", err) + } + if !claimed { + t.Fatal("the first user to sign in must become the system administrator") + } + + // The management plane still reaches everything: the claim owns both tenants, + // which is what the console needs to administer them. + console, err := s.HasConsoleAccess(ctx, admin) + if err != nil { + t.Fatalf("HasConsoleAccess: %v", err) + } + if !console { + t.Error("a system administrator must have console access") + } + tenants, err := s.ListTenants(ctx, admin) + if err != nil { + t.Fatalf("ListTenants: %v", err) + } + if len(tenants) != 2 { + t.Errorf("the administrator owns %d tenants, want 2", len(tenants)) + } + + // The client plane does not. No application was granted, so neither endpoint + // that mints or resolves a hosted-login session will recognize them. + for _, test := range []struct { + name string + application store.Application + }{ + {name: "nmbs", application: firstApp}, + {name: "ypto", application: secondApp}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := s.ClientForAuthorization(ctx, test.application.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ClientForAuthorization = %v, want ErrNotFound: owning a tenant must not open its applications", err) + } + if _, err := s.ClientUserByToken(ctx, secretHash("no-session")); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ClientUserByToken = %v, want ErrNotFound", err) + } + }) + } + + // Attaching the administrator to one application is allowed, and it is the only + // way in. They hold users.manage in every tenant by virtue of owning it, which + // is what makes the deliberate act possible. + ownerRole, err := s.SystemRoleID(ctx, "owner") + if err != nil { + t.Fatalf("look up the owner role: %v", err) + } + if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, []string{firstApp.ID}); err != nil { + t.Fatalf("an administrator must be able to attach themselves to an application: %v", err) + } + if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); err != nil { + t.Fatalf("ClientForAuthorization after an explicit grant = %v, want the application", err) + } + + // Withdrawing it takes effect immediately and leaves the console untouched. + if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, nil); err != nil { + t.Fatalf("withdraw the grant: %v", err) + } + if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ClientForAuthorization after withdrawal = %v, want ErrNotFound", err) + } + console, err = s.HasConsoleAccess(ctx, admin) + if err != nil { + t.Fatalf("HasConsoleAccess: %v", err) + } + if !console { + t.Error("withdrawing an application grant must not cost the administrator their console") + } +} diff --git a/internal/store/installation.go b/internal/store/installation.go index 5fc903f..8e4f989 100644 --- a/internal/store/installation.go +++ b/internal/store/installation.go @@ -98,6 +98,13 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser return tx.Commit() } +// ClaimFirstSystemAdministrator promotes the first user to sign in to system +// administrator and gives them ownership of every tenant that exists at that +// moment. It is a no-op once an administrator exists. +// +// It grants console reach across the installation and nothing more: no +// application grant, so the new administrator cannot sign in to a tenant +// application until one is deliberately given to them. func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -124,11 +131,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string 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 a.id, $1, $1 FROM applications a 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() } diff --git a/web/openapi.json b/web/openapi.json index 1b4655d..e0ff183 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -5,14 +5,7 @@ "version": "0.1.0", "description": "Multi-tenant API-key management and runtime authorization." }, - "servers": [{"url": "/"}], "components": { - "securitySchemes": { - "session": {"type": "apiKey", "in": "cookie", "name": "go_loose_session"}, - "apiKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, - "clientBasic": {"type": "http", "scheme": "basic"}, - "userBearer": {"type": "http", "scheme": "bearer"} - }, "schemas": { "Error": {"type": "object", "properties": {"error": {"type": "string"}}}, "AuthorizationRequest": { diff --git a/web/style.css b/web/style.css index a21a772..41c21f1 100644 --- a/web/style.css +++ b/web/style.css @@ -1,11 +1,11 @@ -:root{--bg:#16161e;--surface:#1f2335;--surface2:#24283b;--text:#c0caf5;--muted:#7aa2f7;--accent:#7dcfff;--accent2:#bb9af7;--good:#9ece6a;--danger:#f7768e;--border:#3b4261;--shadow:rgba(0,0,0,.35)} -[data-theme=catppuccin]{--bg:#1e1e2e;--surface:#181825;--surface2:#313244;--text:#cdd6f4;--muted:#89b4fa;--accent:#94e2d5;--accent2:#cba6f7;--good:#a6e3a1;--danger:#f38ba8;--border:#45475a} -[data-theme=tomorrow]{--bg:#1d1f21;--surface:#282a2e;--surface2:#373b41;--text:#c5c8c6;--muted:#81a2be;--accent:#8abeb7;--accent2:#b294bb;--good:#b5bd68;--danger:#cc6666;--border:#4d5057} -[data-theme=kanagawa]{--bg:#1f1f28;--surface:#2a2a37;--surface2:#363646;--text:#dcd7ba;--muted:#7e9cd8;--accent:#7fb4ca;--accent2:#957fb8;--good:#98bb6c;--danger:#e46876;--border:#54546d} -[data-mode=light][data-theme=tokyo]{--bg:#e6e7ed;--surface:#fff;--surface2:#dfe2ed;--text:#343b58;--muted:#34548a;--accent:#0f4b6e;--accent2:#5a4a78;--good:#485e30;--danger:#8c4351;--border:#b4b5c3;--shadow:rgba(52,59,88,.15)} -[data-mode=light][data-theme=catppuccin]{--bg:#eff1f5;--surface:#fff;--surface2:#e6e9ef;--text:#4c4f69;--muted:#1e66f5;--accent:#179299;--accent2:#8839ef;--good:#40a02b;--danger:#d20f39;--border:#bcc0cc;--shadow:rgba(76,79,105,.15)} -[data-mode=light][data-theme=tomorrow]{--bg:#f5f5f5;--surface:#fff;--surface2:#e8e8e8;--text:#4d4d4c;--muted:#4271ae;--accent:#3e999f;--accent2:#8959a8;--good:#718c00;--danger:#c82829;--border:#c9c9c9;--shadow:rgba(77,77,76,.15)} -[data-mode=light][data-theme=kanagawa]{--bg:#f2ecde;--surface:#fffaf0;--surface2:#e7dba9;--text:#545464;--muted:#4d699b;--accent:#4d7f8c;--accent2:#624c83;--good:#6f894e;--danger:#c34043;--border:#c8c093;--shadow:rgba(84,84,100,.15)} +:root{--bg:var(--bn-bg,#16161e);--surface:var(--bn-surface,#1f2335);--surface2:var(--bn-surface-raised,#24283b);--text:var(--bn-text,#c0caf5);--muted:var(--bn-muted,#7aa2f7);--accent:var(--bn-accent,#7dcfff);--accent2:var(--bn-secondary,#bb9af7);--good:var(--bn-good,#9ece6a);--danger:var(--bn-danger,#f7768e);--border:var(--bn-border,#3b4261);--shadow:rgba(0,0,0,.35)} +[data-theme=catppuccin]{--bg:var(--bn-bg,#1e1e2e);--surface:var(--bn-surface,#181825);--surface2:var(--bn-surface-raised,#313244);--text:var(--bn-text,#cdd6f4);--muted:var(--bn-muted,#89b4fa);--accent:var(--bn-accent,#94e2d5);--accent2:var(--bn-secondary,#cba6f7);--good:var(--bn-good,#a6e3a1);--danger:var(--bn-danger,#f38ba8);--border:var(--bn-border,#45475a)} +[data-theme=tomorrow]{--bg:var(--bn-bg,#1d1f21);--surface:var(--bn-surface,#282a2e);--surface2:var(--bn-surface-raised,#373b41);--text:var(--bn-text,#c5c8c6);--muted:var(--bn-muted,#81a2be);--accent:var(--bn-accent,#8abeb7);--accent2:var(--bn-secondary,#b294bb);--good:var(--bn-good,#b5bd68);--danger:var(--bn-danger,#cc6666);--border:var(--bn-border,#4d5057)} +[data-theme=kanagawa]{--bg:var(--bn-bg,#1f1f28);--surface:var(--bn-surface,#2a2a37);--surface2:var(--bn-surface-raised,#363646);--text:var(--bn-text,#dcd7ba);--muted:var(--bn-muted,#7e9cd8);--accent:var(--bn-accent,#7fb4ca);--accent2:var(--bn-secondary,#957fb8);--good:var(--bn-good,#98bb6c);--danger:var(--bn-danger,#e46876);--border:var(--bn-border,#54546d)} +[data-mode=light][data-theme=tokyo]{--bg:var(--bn-bg,#e6e7ed);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#dfe2ed);--text:var(--bn-text,#343b58);--muted:var(--bn-muted,#34548a);--accent:var(--bn-accent,#0f4b6e);--accent2:var(--bn-secondary,#5a4a78);--good:var(--bn-good,#485e30);--danger:var(--bn-danger,#8c4351);--border:var(--bn-border,#b4b5c3);--shadow:rgba(52,59,88,.15)} +[data-mode=light][data-theme=catppuccin]{--bg:var(--bn-bg,#eff1f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e6e9ef);--text:var(--bn-text,#4c4f69);--muted:var(--bn-muted,#1e66f5);--accent:var(--bn-accent,#179299);--accent2:var(--bn-secondary,#8839ef);--good:var(--bn-good,#40a02b);--danger:var(--bn-danger,#d20f39);--border:var(--bn-border,#bcc0cc);--shadow:rgba(76,79,105,.15)} +[data-mode=light][data-theme=tomorrow]{--bg:var(--bn-bg,#f5f5f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e8e8e8);--text:var(--bn-text,#4d4d4c);--muted:var(--bn-muted,#4271ae);--accent:var(--bn-accent,#3e999f);--accent2:var(--bn-secondary,#8959a8);--good:var(--bn-good,#718c00);--danger:var(--bn-danger,#c82829);--border:var(--bn-border,#c9c9c9);--shadow:rgba(77,77,76,.15)} +[data-mode=light][data-theme=kanagawa]{--bg:var(--bn-bg,#f2ecde);--surface:var(--bn-surface,#fffaf0);--surface2:var(--bn-surface-raised,#e7dba9);--text:var(--bn-text,#545464);--muted:var(--bn-muted,#4d699b);--accent:var(--bn-accent,#4d7f8c);--accent2:var(--bn-secondary,#624c83);--good:var(--bn-good,#6f894e);--danger:var(--bn-danger,#c34043);--border:var(--bn-border,#c8c093);--shadow:rgba(84,84,100,.15)} *{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} .docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)} main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)} From 0f5d582d1f17f3f8b3a6fc9e2215d4f440615bed Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Wed, 7 Oct 2026 15:07:22 +0200 Subject: [PATCH 10/10] chore: helm configuration --- deploy/helm/go-loose/Chart.yaml | 23 ++++ deploy/helm/go-loose/templates/NOTES.txt | 19 +++ deploy/helm/go-loose/templates/_helpers.tpl | 60 ++++++++ .../helm/go-loose/templates/deployment.yaml | 68 ++++++++++ deploy/helm/go-loose/templates/hpa.yaml | 23 ++++ deploy/helm/go-loose/templates/ingress.yaml | 36 +++++ deploy/helm/go-loose/templates/secret.yaml | 12 ++ deploy/helm/go-loose/templates/service.yaml | 16 +++ .../go-loose/templates/serviceaccount.yaml | 13 ++ deploy/helm/go-loose/values-prd.yaml | 95 +++++++++++++ deploy/helm/go-loose/values.yaml | 128 ++++++++++++++++++ docker-compose.tst.yaml | 65 +++++++++ 12 files changed, 558 insertions(+) create mode 100644 deploy/helm/go-loose/Chart.yaml create mode 100644 deploy/helm/go-loose/templates/NOTES.txt create mode 100644 deploy/helm/go-loose/templates/_helpers.tpl create mode 100644 deploy/helm/go-loose/templates/deployment.yaml create mode 100644 deploy/helm/go-loose/templates/hpa.yaml create mode 100644 deploy/helm/go-loose/templates/ingress.yaml create mode 100644 deploy/helm/go-loose/templates/secret.yaml create mode 100644 deploy/helm/go-loose/templates/service.yaml create mode 100644 deploy/helm/go-loose/templates/serviceaccount.yaml create mode 100644 deploy/helm/go-loose/values-prd.yaml create mode 100644 deploy/helm/go-loose/values.yaml create mode 100644 docker-compose.tst.yaml diff --git a/deploy/helm/go-loose/Chart.yaml b/deploy/helm/go-loose/Chart.yaml new file mode 100644 index 0000000..3f6e183 --- /dev/null +++ b/deploy/helm/go-loose/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: go-loose +description: Authentication and authorization service for urpi platform. Provides OIDC, session management, and tenant isolation. +type: application +version: 1.0.0 +appVersion: "1.0.0" +kubeVersion: ">=1.28.0-0" +keywords: + - authentication + - authorization + - oidc + - identity + - sso +icon: https://raw.githubusercontent.com/urpi/go-loose/main/docs/assets/logo.svg +home: https://github.com/urpi/go-loose +sources: + - https://github.com/urpi/go-loose +maintainers: + - name: urpi platform team + url: https://github.com/urpi +annotations: + category: Security + licenses: MIT \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/NOTES.txt b/deploy/helm/go-loose/templates/NOTES.txt new file mode 100644 index 0000000..bc94cbb --- /dev/null +++ b/deploy/helm/go-loose/templates/NOTES.txt @@ -0,0 +1,19 @@ +go-loose has been deployed to namespace {{ .Release.Namespace }}. + +Access the service: + https://auth.urpi.be + +Health check: + curl https://auth.urpi.be/healthz + +PostgreSQL is deployed as a sub-chart. Credentials are in: + kubectl get secret {{ include "go-loose.fullname" . }}-postgresql -n {{ .Release.Namespace }} -o jsonpath='{.data.postgres-password}' | base64 -d + +Environment variables are stored in secret: + kubectl get secret {{ include "go-loose.fullname" . }}-env -n {{ .Release.Namespace }} -o yaml + +To update secrets (SESSION_SECRET, DATABASE_URL): + kubectl patch secret {{ include "go-loose.fullname" . }}-env -n {{ .Release.Namespace }} -p '{"stringData":{"GO_LOOSE_SESSION_SECRET":"new-secret"}}' + kubectl rollout restart deployment {{ include "go-loose.fullname" . }} -n {{ .Release.Namespace }} + +Default admin user is created on first startup via GO_LOOSE_LOCAL_LOGIN=true. \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/_helpers.tpl b/deploy/helm/go-loose/templates/_helpers.tpl new file mode 100644 index 0000000..3f5e6b7 --- /dev/null +++ b/deploy/helm/go-loose/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "go-loose.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "go-loose.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "go-loose.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "go-loose.labels" -}} +helm.sh/chart: {{ include "go-loose.chart" . }} +{{ include "go-loose.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "go-loose.selectorLabels" -}} +app.kubernetes.io/name: {{ include "go-loose.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "go-loose.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "go-loose.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/deployment.yaml b/deploy/helm/go-loose/templates/deployment.yaml new file mode 100644 index 0000000..cf51f60 --- /dev/null +++ b/deploy/helm/go-loose/templates/deployment.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "go-loose.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-loose.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "go-loose.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "go-loose.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "go-loose.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- range $key, $value := .Values.envSecrets }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/hpa.yaml b/deploy/helm/go-loose/templates/hpa.yaml new file mode 100644 index 0000000..ab86f6f --- /dev/null +++ b/deploy/helm/go-loose/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "go-loose.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-loose.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "go-loose.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/ingress.yaml b/deploy/helm/go-loose/templates/ingress.yaml new file mode 100644 index 0000000..95e1b28 --- /dev/null +++ b/deploy/helm/go-loose/templates/ingress.yaml @@ -0,0 +1,36 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "go-loose.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-loose.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ include "go-loose.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/secret.yaml b/deploy/helm/go-loose/templates/secret.yaml new file mode 100644 index 0000000..5c0fab9 --- /dev/null +++ b/deploy/helm/go-loose/templates/secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "go-loose.fullname" . }}-env + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-loose.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $key, $value := .Values.envSecrets }} + {{ $key }}: {{ $value | quote }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/service.yaml b/deploy/helm/go-loose/templates/service.yaml new file mode 100644 index 0000000..eee9358 --- /dev/null +++ b/deploy/helm/go-loose/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "go-loose.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-loose.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "go-loose.selectorLabels" . | nindent 4 }} \ No newline at end of file diff --git a/deploy/helm/go-loose/templates/serviceaccount.yaml b/deploy/helm/go-loose/templates/serviceaccount.yaml new file mode 100644 index 0000000..6848881 --- /dev/null +++ b/deploy/helm/go-loose/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "go-loose.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-loose.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-loose/values-prd.yaml b/deploy/helm/go-loose/values-prd.yaml new file mode 100644 index 0000000..4ed5fda --- /dev/null +++ b/deploy/helm/go-loose/values-prd.yaml @@ -0,0 +1,95 @@ +# PRD values for go-loose +replicaCount: 1 + +imagePullSecrets: + - name: nexus-registry + +image: + repository: nexus-registry.urpi.be/docker-hosted/go-loose + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + +ingress: + enabled: true + className: traefik + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +# nginx.ingress.kubernetes.io/ssl-redirect: "true" +# nginx.ingress.kubernetes.io/proxy-body-size: "10m" + hosts: + - host: auth.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-loose-tls + hosts: + - auth.urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + +postgresql: + enabled: true + auth: + database: goloose + username: goloose + password: "" + primary: + persistence: + enabled: true + size: 10Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 512Mi + +resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 512Mi + +env: + GO_LOOSE_ADDR: ":8080" + GO_LOOSE_BASE_URL: "https://auth.urpi.be" + GO_LOOSE_AUTH_DOMAIN: "auth.urpi.be" + GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.urpi.be/auth/callback" + GO_LOOSE_DEV_LOGIN: "false" + GO_LOOSE_LOCAL_LOGIN: "true" + GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true" + +envSecrets: + GO_LOOSE_DATABASE_URL: "postgres://goloose:changeme@go-loose-postgresql:5432/goloose?sslmode=disable" + GO_LOOSE_SESSION_SECRET: "changeme-generate-secure-random" + +livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + +readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 \ No newline at end of file diff --git a/deploy/helm/go-loose/values.yaml b/deploy/helm/go-loose/values.yaml new file mode 100644 index 0000000..5e49e67 --- /dev/null +++ b/deploy/helm/go-loose/values.yaml @@ -0,0 +1,128 @@ +replicaCount: 1 + +imagePullSecrets: + - name: nexus-registry + +image: + repository: nexus.urpi.be/docker-hosted/go-loose + tag: "0.0.1" + pullPolicy: Always + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + hosts: + - host: auth.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-loose-tls + hosts: + - auth.urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + +postgresql: + enabled: true + auth: + database: goloose + username: goloose + password: changeme + primary: + persistence: + enabled: true + size: 5Gi + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +env: + GO_LOOSE_ADDR: ":8080" + GO_LOOSE_BASE_URL: "https://auth.urpi.be" + GO_LOOSE_AUTH_DOMAIN: "auth.urpi.be" + GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.urpi.be/auth/callback" + GO_LOOSE_DEV_LOGIN: "false" + GO_LOOSE_LOCAL_LOGIN: "true" + GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true" + +envSecrets: + GO_LOOSE_DATABASE_URL: "" + GO_LOOSE_SESSION_SECRET: "" + +livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 5 + +readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +nodeSelector: {} +tolerations: [] +affinity: {} \ No newline at end of file diff --git a/docker-compose.tst.yaml b/docker-compose.tst.yaml new file mode 100644 index 0000000..c05d46d --- /dev/null +++ b/docker-compose.tst.yaml @@ -0,0 +1,65 @@ +# TST stack: docker compose -f docker-compose.tst up --build +# +# Go Loose for testing environment. Similar to dev but pointed at tst services. +# +services: + postgres: + image: postgres:17-alpine + ports: + - "${GO_LOOSE_POSTGRES_PORT:-5433}:5432" + environment: + POSTGRES_DB: goloose + POSTGRES_USER: goloose + POSTGRES_PASSWORD: goloose + volumes: + - db-data-tst:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"] + interval: 5s + timeout: 3s + retries: 10 + networks: + - go_loose_tst_network + + go-loose: + build: + context: . + dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + environment: + GO_LOOSE_ADDR: ":8080" + GO_LOOSE_BASE_URL: "${GO_LOOSE_BASE_URL:-http://tst-intern-urpi.be:8080}" + GO_LOOSE_AUTH_DOMAIN: "${GO_LOOSE_AUTH_DOMAIN:-auth-tst.intern-urpi.be}" + GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable" + GO_LOOSE_SESSION_SECRET: "${GO_LOOSE_SESSION_SECRET:-tst-session-secret-change-me}" + GO_LOOSE_DEV_LOGIN: "false" + GO_LOOSE_LOCAL_LOGIN: "false" + GO_LOOSE_LOCAL_ADMIN_EMAIL: "admin@tst.goloose.local" + GO_LOOSE_LOCAL_ADMIN_PASSWORD: "${GO_LOOSE_LOCAL_ADMIN_PASSWORD:-}" + GO_LOOSE_ALLOWED_EMAIL_DOMAINS: "" + GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true" + GO_LOOSE_BOOTSTRAP_TENANT: "nmbs" + GO_LOOSE_BOOTSTRAP_APP: "guess" + GO_LOOSE_OIDC_ISSUER: "https://accounts.google.com" + GO_LOOSE_OIDC_CLIENT_ID: "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com" + GO_LOOSE_OIDC_CLIENT_SECRET: "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" + GO_LOOSE_OIDC_REDIRECT_URL: "https://auth-tst.intern-urpi.be/auth/callback" + ports: + - "${GO_LOOSE_TST_PORT:-8080}:8080" + healthcheck: + test: ["CMD", "/src/dist/go-loose", "-healthcheck"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 20s + init: true + networks: + - go_loose_tst_network + +networks: + go_loose_tst_network: + +volumes: + db-data-tst: \ No newline at end of file