From 6319da650ce30c35088ffaa21235484cc21b4f00 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 4 Oct 2026 12:24:13 +0200 Subject: [PATCH] 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}