${escapeHTML(item.name)}
${escapeHTML(item.slug)}
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
+ ${escapeHTML(item.slug)} ${escapeHTML(item.description || 'No description')}
+```
+
+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.name)}
${escapeHTML(item.name)}
${escapeHTML(item.prefix)}…${escapeHTML(item.description || 'No description')}
${canManageApplications ? `` : ''}${escapeHTML(item.prefix)}…${text}
SERVICE CATALOG
SERVICE CATALOG
CREDENTIALS
CREDENTIALS
| Name | Application | Prefix | Status | Last used |
|---|
DISCOVERY
DISCOVERY
| Application | OpenAPI | Endpoints | Source | Imported |
|---|
CLIENT IDENTITIES
CLIENT IDENTITIES
| User | Tenant | Role | Status | Applications |
|---|
Roles come from the shared permission matrix. Open your own profile to change your name and picture.
██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗ ██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝ @@ -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}}$ █
YOUR ACCOUNT
+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.
+| Tenant | Slug | Role | Capabilities |
|---|
${escapeHTML(item.tenant_slug)}