fix installer

This commit is contained in:
Daniel Noulet 2026-10-04 12:24:13 +02:00
parent 318fb51085
commit 6319da650c
31 changed files with 2391 additions and 87 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ bin/
coverage.out coverage.out
google_secrets.json google_secrets.json
data/

View file

@ -12,7 +12,7 @@ GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true
export export
.PHONY: build test run compose-up compose-down fmt vet .PHONY: build test integration-test run compose-up compose-down fmt vet
build: build:
go build ./... go build ./...
@ -20,6 +20,12 @@ build:
test: test:
go test -race ./... 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: run:
go run ./cmd/go-loose go run ./cmd/go-loose

View file

@ -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) - [First-run installation, waiting room, and demo accounts](docs/installation.md)
- [Go middleware integration](docs/integration.md) - [Go middleware integration](docs/integration.md)
- [Client application login](docs/client-login.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) - [Database user authentication and local credentials](docs/password-authentication.md)
- [Operations, migrations, and OIDC](docs/operations.md) - [Operations, migrations, and OIDC](docs/operations.md)
- [Coding standards](docs/coding-standards.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 ## Development
```bash ```bash
@ -77,3 +82,6 @@ make build
Development requires Go 1.27.1 or newer. 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. 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.

View file

@ -41,10 +41,28 @@ type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"` TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"` TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"` ApplicationID string `json:"application_id"`
Application string `json:"application"` 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{} type userContextKey struct{}

View file

@ -8,7 +8,7 @@ services:
POSTGRES_USER: goloose POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose POSTGRES_PASSWORD: goloose
volumes: volumes:
- postgres-data2:/var/lib/postgresql/data - ./data/db:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"] test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
interval: 5s interval: 5s
@ -48,8 +48,6 @@ services:
aliases: aliases:
- go-loose - go-loose
volumes:
postgres-data2:
networks: networks:
go_loose_network: go_loose_network:
local_dev_edge: local_dev_edge:

View file

@ -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. 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 ## Security and availability
- Use TLS for both Go Loose and the client application. - Use TLS for both Go Loose and the client application.

View file

@ -8,6 +8,7 @@
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys. - Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
- Return explicit errors. Runtime authorization must fail closed. - 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. - 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. - Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`. - Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.

53
docs/roles.md Normal file
View file

@ -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
<img src="https://loose.example.com/api/v1/avatars/kZ3..." alt="Ada Lovelace" width="64" height="64">
```
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`.

143
internal/avatar/avatar.go Normal file
View file

@ -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 "", ""
}

View file

@ -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(`<svg xmlns="http://www.w3.org/2000/svg"/>`)), 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)
}
}

View file

@ -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'));

175
internal/role/role.go Normal file
View file

@ -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
}

135
internal/role/role_test.go Normal file
View file

@ -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)
}
}
}

View file

@ -8,9 +8,35 @@ import (
"time" "time"
"github.com/danyel/go-loose/internal/key" "github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store" "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) { func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state") 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{ writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer", "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) s.internalError(w, "load client user", err)
return return
} }
writeJSON(w, http.StatusOK, user) writeJSON(w, http.StatusOK, s.clientUser(user))
} }
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {

143
internal/server/profile.go Normal file
View file

@ -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
}

View file

@ -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("<svg/>")) + `"}`},
{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()
}

View file

@ -22,6 +22,7 @@ import (
"github.com/danyel/go-loose/internal/config" "github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/contract" "github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/key" "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/secretbox"
"github.com/danyel/go-loose/internal/session" "github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store" "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/logout", s.authLogout)
mux.HandleFunc("POST /auth/password", s.passwordLogin) mux.HandleFunc("POST /auth/password", s.passwordLogin)
mux.HandleFunc("GET /assets/{name}", s.asset) 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("POST /api/v1/authorize", s.authorize)
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize) mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
mux.HandleFunc("POST /connect/token", s.clientToken) 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 // {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
// browser favicon request would redirect to /install and rotate its CSRF cookie. // browser favicon request would redirect to /install and rotate its CSRF cookie.
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index))) 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 /docs", s.requireSession(http.HandlerFunc(s.docs)))
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi))) mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard))) 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) { 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 { if err != nil {
s.internalError(w, "check management access", err) s.internalError(w, "check console access", err)
return return
} }
if !allowed { if console {
s.serveEmbedded(w, "waiting-room.html") s.serveEmbedded(w, "index.html")
return 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) { 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) { func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name")) 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) http.NotFound(w, r)
return 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) { func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r) claims := claimsFrom(r)
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID) allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil { if err != nil {
s.internalError(w, "check management access", err) s.internalError(w, "check console access", err)
return return
} }
if !allowed { if !allowed {
writeError(w, http.StatusForbidden, "waiting for administrator approval") writeError(w, http.StatusForbidden, "this account has no console access")
return return
} }
tenants, err := s.store.ListTenants(r.Context(), claims.UserID) 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) s.internalError(w, "check system administrator", err)
return 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{ writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts, "user": claims, "profile": s.profileResponse(profile),
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator, "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 { 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 := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil { if err := decoder.Decode(target); err != nil {

View file

@ -138,12 +138,14 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
} }
tenantSlug := s.requestTenantSlug(r.Host) tenantSlug := s.requestTenantSlug(r.Host)
tenantLogin := tenantSlug != "" tenantLogin := tenantSlug != ""
loginPrompt := "admin@goloose:~"
loginContext := "SYSTEM ADMINISTRATION" loginContext := "SYSTEM ADMINISTRATION"
loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant." loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant."
passwordButton := "LOGIN" passwordButton := "LOGIN"
if tenantLogin { if tenantLogin {
loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION" loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION"
loginMessage = "Sign in with your tenant database account." loginMessage = "Sign in with your tenant database account."
loginPrompt = tenantSlug + "@goloose:~"
passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug) passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug)
} }
page := strings.NewReplacer( page := strings.NewReplacer(
@ -153,6 +155,7 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
"{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton), "{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton),
"{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin), "{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()), "{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()),
"{{LOGIN_PROMPT}}", loginPrompt,
).Replace(string(content)) ).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page)) w.Write([]byte(page))

View file

@ -8,8 +8,28 @@ import (
"github.com/danyel/go-loose/internal/key" "github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/password" "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) { func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
var request struct { var request struct {
RedirectURIs []string `json:"redirect_uris"` RedirectURIs []string `json:"redirect_uris"`
@ -75,8 +95,9 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
if request.DisplayName == "" { if request.DisplayName == "" {
request.DisplayName = request.Email request.DisplayName = request.Email
} }
if request.Role != "admin" && request.Role != "viewer" { invitedRole, ok := invitableRole(request.Role)
writeError(w, http.StatusBadRequest, "role must be admin or viewer") if !ok {
writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", "))
return return
} }
passwordHash, err := password.Hash(request.Password) 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()) writeError(w, http.StatusBadRequest, err.Error())
return 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 { if err != nil {
s.handleStoreError(w, "invite user", err) s.handleStoreError(w, "invite user", err)
return return
} }
user.AvatarURL = s.avatarURL(user.AvatarKey)
writeJSON(w, http.StatusCreated, user) writeJSON(w, http.StatusCreated, user)
} }
@ -101,11 +123,12 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &request) { if !decodeJSON(w, r, &request) {
return return
} }
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" { membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role") writeError(w, http.StatusBadRequest, "invalid role")
return 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) s.handleStoreError(w, "update user access", err)
return return
} }
@ -121,13 +144,14 @@ func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &request) { if !decodeJSON(w, r, &request) {
return return
} }
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" { membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role") writeError(w, http.StatusBadRequest, "invalid role")
return return
} }
if err := s.store.ApprovePendingUser( if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
request.Role, request.ApplicationIDs, membershipRole, request.ApplicationIDs,
); err != nil { ); err != nil {
s.handleStoreError(w, "approve pending user", err) s.handleStoreError(w, "approve pending user", err)
return return

View file

@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"time" "time"
"github.com/danyel/go-loose/internal/role"
) )
type ManagedUser struct { type ManagedUser struct {
@ -14,8 +16,13 @@ type ManagedUser struct {
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Role string `json:"role"` Role string `json:"role"`
Permissions []string `json:"permissions"`
Status string `json:"status"` Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"` 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 { type ClientApplication struct {
@ -36,12 +43,21 @@ type ClientUser struct {
TenantSlug string `json:"tenant_slug"` TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"` ApplicationID string `json:"application_id"`
Application string `json:"application"` 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) { func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role, SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END, CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
u.avatar_key,
COALESCE(( COALESCE((
SELECT json_agg(ua.application_id ORDER BY ua.application_id) SELECT json_agg(ua.application_id ORDER BY ua.application_id)
FROM user_application_access ua FROM user_application_access ua
@ -53,9 +69,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM memberships own SELECT 1 FROM memberships own
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1 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 { if err != nil {
return nil, err return nil, err
} }
@ -64,18 +80,20 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
for rows.Next() { for rows.Next() {
var user ManagedUser var user ManagedUser
var applicationIDs []byte 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 return nil, err
} }
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil { if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
return nil, err return nil, err
} }
user.Permissions = role.Role(user.Role).Permissions()
result = append(result, user) result = append(result, user)
} }
return result, rows.Err() 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) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return ManagedUser{}, err return ManagedUser{}, err
@ -85,8 +103,8 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
if err := tx.QueryRowContext(ctx, ` if err := tx.QueryRowContext(ctx, `
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM memberships SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin') WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3)
)`, tenantID, administratorID).Scan(&permitted); err != nil { )`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil {
return ManagedUser{}, err return ManagedUser{}, err
} }
if !permitted { if !permitted {
@ -109,17 +127,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3) INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, 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 return ManagedUser{}, err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return ManagedUser{}, err 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 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) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err return err
@ -130,8 +149,8 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
FROM memberships administrator FROM memberships administrator
WHERE target.tenant_id = $1 AND target.user_id = $2 WHERE target.tenant_id = $1 AND target.user_id = $2
AND administrator.tenant_id = target.tenant_id AND administrator.tenant_id = target.tenant_id
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`, AND administrator.user_id = $3 AND administrator.role = ANY($5)`,
tenantID, userID, administratorID, role) tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers))
if err != nil { if err != nil {
return err return err
} }
@ -165,12 +184,12 @@ func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicatio
var encoded []byte var encoded []byte
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
UPDATE applications a 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 FROM memberships m
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2 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`, 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) Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound return Application{}, ErrNotFound
@ -246,14 +265,20 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
var user ClientUser var user ClientUser
err = tx.QueryRowContext(ctx, ` err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now() 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() 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 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 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,
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). codeHash, redirectURI, clientID, secretHash, role.User).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application) 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) { if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound return ClientUser{}, time.Time{}, ErrNotFound
} }
@ -273,12 +298,18 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client
var user ClientUser var user ClientUser
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now() 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() 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 a.id = s.application_id AND ua.user_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,
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`, COALESCE(m.role, $2), u.avatar_key`,
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application) 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) { if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound return ClientUser{}, ErrNotFound
} }

View file

@ -4,6 +4,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"github.com/danyel/go-loose/internal/role"
) )
type Installation struct { type Installation struct {
@ -78,8 +80,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
return err return err
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin') INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil { ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role.Admin); err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
@ -113,7 +116,7 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) 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 return false, err
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
@ -124,11 +127,24 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
return true, tx.Commit() 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 var allowed bool
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1) 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 return allowed, err
} }

View file

@ -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
}

150
internal/store/profile.go Normal file
View file

@ -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
}

View file

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/danyel/go-loose/internal/contract" "github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/role"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
@ -29,6 +30,9 @@ type Tenant struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Name string `json:"name"` Name string `json:"name"`
Role string `json:"role"` 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 { type Application struct {
@ -86,8 +90,15 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri
return User{}, err return User{}, err
} }
defer tx.Rollback() 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, ` 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) WHERE lower(email) = lower($2)
RETURNING id, email, display_name`, subject, email, displayName). RETURNING id, email, display_name`, subject, email, displayName).
Scan(&user.ID, &user.Email, &user.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, ` if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) 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) 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) 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 return err
} }
if _, err = tx.ExecContext(ctx, ` 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 { if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
return nil, err return nil, err
} }
item.Permissions = role.Role(item.Role).Permissions()
result = append(result, item) result = append(result, item)
} }
return result, rows.Err() 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 { 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 return Tenant{}, err
} }
tenant.Role = "owner" tenant.Role = string(role.Owner)
tenant.Permissions = role.Owner.Permissions()
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return Tenant{}, err 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) INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
SELECT $1, $2, $3, $4, $5 SELECT $1, $2, $3, $4, $5
WHERE EXISTS ( 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), RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
client_id, to_json(redirect_uris), false`, 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 var allowedHosts, redirectURIs []byte
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady) 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) { 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 SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
FROM applications a FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id 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`, 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) Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound return APIKey{}, ErrNotFound
@ -287,8 +302,8 @@ func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := s.db.ExecContext(ctx, ` result, err := s.db.ExecContext(ctx, `
UPDATE api_keys k SET status = 'revoked', revoked_at = now() UPDATE api_keys k SET status = 'revoked', revoked_at = now()
FROM memberships m 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')`, WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`,
keyID, userID) keyID, userID, role.Grants(role.ManageKeys))
if err != nil { if err != nil {
return err return err
} }
@ -349,9 +364,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
SELECT a.id, $2, $3, $4, $5 SELECT a.id, $2, $3, $4, $5
FROM applications a FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id 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`, 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) Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Contract{}, ErrNotFound return Contract{}, ErrNotFound

View file

@ -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 $ = selector => document.querySelector(selector);
const roleOptions = roles => roles
.map(item => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
.join('');
const $$ = selector => [...document.querySelectorAll(selector)]; const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '&': '&amp;',
@ -38,14 +48,38 @@ async function load() {
const data = await api('/api/v1/dashboard'); const data = await api('/api/v1/dashboard');
Object.assign(state, data); Object.assign(state, data);
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || ''; 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(); 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() { function render() {
const tenantID = state.selectedTenant; const tenantID = state.selectedTenant;
const selectedTenant = state.tenants.find(item => item.id === tenantID); 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 tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
const tenantAppIDs = new Set(tenantApps.map(item => item.id)); const tenantAppIDs = new Set(tenantApps.map(item => item.id));
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_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 => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join(''); $('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
$('#new-tenant').hidden = !state.is_system_administrator; $('#new-tenant').hidden = !state.is_system_administrator;
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet'); $('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet');
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageTenant ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet'); $('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageApplications ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageTenant && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued'); $('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageKeys && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
$('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported'); $('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported');
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : ''; const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : '';
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageTenant ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users'); $('#user-list').innerHTML = tenantUsers.map(item => `<tr><td>${avatarCell(item)}<strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageUsers ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users');
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant); $$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
$$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers);
$$('.tenant-select').forEach(select => { $$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''); select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
select.value = tenantID; select.value = tenantID;
@ -74,6 +111,10 @@ function render() {
} }
const empty = text => `<article class="card"><p>${text}</p></article>`; const empty = text => `<article class="card"><p>${text}</p></article>`;
const initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?';
const avatarCell = item => item.avatar_url
? `<img class="avatar" src="${escapeHTML(item.avatar_url)}" alt="" width="28" height="28" loading="lazy">`
: `<span class="avatar avatar-empty">${escapeHTML(initials(item.display_name || item.email))}</span>`;
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`; const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
function formJSON(form) { function formJSON(form) {

View file

@ -18,7 +18,7 @@
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select> <select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
</label> </label>
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button> <button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
<span id="identity" class="identity"></span> <a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form> <form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
</nav> </nav>
</header> </header>
@ -58,15 +58,15 @@
<div class="panels"> <div class="panels">
<section class="panel active" id="applications"> <section class="panel active" id="applications">
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-action>New application</button></div> <div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-application-action>New application</button></div>
<div class="card-grid" id="app-list"></div> <div class="card-grid" id="app-list"></div>
</section> </section>
<section class="panel" id="keys"> <section class="panel" id="keys">
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-action>Issue key</button></div> <div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-key-action>Issue key</button></div>
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div>
</section> </section>
<section class="panel" id="contracts"> <section class="panel" id="contracts">
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-action>Import contract</button></div> <div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-contract-action>Import contract</button></div>
<div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div>
</section> </section>
<section class="panel" id="tenants"> <section class="panel" id="tenants">
@ -74,8 +74,9 @@
<div class="card-grid" id="tenant-list"></div> <div class="card-grid" id="tenant-list"></div>
</section> </section>
<section class="panel" id="users"> <section class="panel" id="users">
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-action>Invite user</button></div> <div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-user-action>Invite user</button></div>
<div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div>
<p class="muted">Roles come from the shared permission matrix. <a href="/profile">Open your own profile</a> to change your name and picture.</p>
</section> </section>
</div> </div>
</section> </section>
@ -86,8 +87,8 @@
<dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog> <dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog>
<dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0&#10;paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog> <dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0&#10;paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog>
<dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog> <dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label><button type="submit">Invite user</button></form></dialog> <dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role" id="invite-role" required></select></label><button type="submit">Invite user</button></form></dialog>
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option><option value="owner">Owner</option></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog> <dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role" id="access-role" required></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
<dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog> <dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog>
<script src="/assets/app.js" defer></script> <script src="/assets/app.js" defer></script>

View file

@ -4,7 +4,7 @@
<body class="login-page"> <body class="login-page">
<div class="login-glow"></div> <div class="login-glow"></div>
<main class="terminal"> <main class="terminal">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@access-plane:~</strong></div> <div class="terminal-bar"><span></span><span></span><span></span><strong>{{LOGIN_PROMPT}}</strong></div>
<div class="terminal-body"> <div class="terminal-body">
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗ <pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝ ██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
@ -12,9 +12,9 @@
██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝ ██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗ ╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre> ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<p class="eyebrow">{{LOGIN_CONTEXT}}</p> <p class="eyebrow">{{LOGIN_CONTEXT}}</p>
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p> <p><span class="prompt">{{LOGIN_PROMPT}}$</span> initialize secure session</p>
<p class="muted">{{LOGIN_MESSAGE}}</p> <p class="muted">{{LOGIN_MESSAGE}}</p>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post"> <form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}"> <input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Email<input type="email" name="email" autocomplete="username" required></label> <label>Email<input type="email" name="email" autocomplete="username" required></label>
@ -23,7 +23,7 @@
</form> </form>
<div style="{{SSO_STYLE}}"> <div style="{{SSO_STYLE}}">
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div> <a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p> <p class="cursor-line"><span class="prompt">{{LOGIN_PROMPT}}$</span> <span class="cursor">█</span></p>
</div> </div>
</main> </main>
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script> <script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>

View file

@ -38,6 +38,50 @@
"api_key_id": {"type": "string", "format": "uuid"}, "api_key_id": {"type": "string", "format": "uuid"},
"api_key_name": {"type": "string"} "api_key_name": {"type": "string"}
} }
},
"Membership": {
"type": "object",
"description": "A tenant role held by a user, with the capabilities it grants.",
"properties": {
"tenant_id": {"type": "string", "format": "uuid"},
"tenant_slug": {"type": "string"},
"tenant_name": {"type": "string"},
"role": {"type": "string", "enum": ["owner", "admin", "developer", "operator", "viewer", "user"]},
"permissions": {"type": "array", "items": {"type": "string"}}
}
},
"Profile": {
"type": "object",
"properties": {
"id": {"type": "string", "format": "uuid"},
"email": {"type": "string", "format": "email"},
"display_name": {"type": "string"},
"avatar_url": {"type": "string", "description": "Absolute picture URL, or an empty string when no picture is set."},
"memberships": {"type": "array", "items": {"$ref": "#/components/schemas/Membership"}},
"is_system_administrator": {"type": "boolean"},
"name_customized": {"type": "boolean", "description": "True once the user set the display name themselves, which stops the identity provider from overwriting it."},
"created_at": {"type": "string", "format": "date-time"},
"last_login_at": {"type": "string", "format": "date-time"},
"profile_updated_at": {"type": "string", "format": "date-time"},
"avatar_updated_at": {"type": "string", "format": "date-time"}
}
},
"ProfileUpdate": {
"type": "object",
"description": "Send display_name, avatar, or remove_avatar. avatar accepts raw base64 or a data URL.",
"properties": {
"display_name": {"type": "string", "minLength": 1, "maxLength": 200},
"avatar": {"type": "string", "description": "Base64 or data URL of a JPEG, PNG, or GIF up to 2 MiB and 4096 by 4096 pixels."},
"remove_avatar": {"type": "boolean"}
}
},
"RoleCatalogEntry": {
"type": "object",
"properties": {
"value": {"type": "string"},
"label": {"type": "string"},
"permissions": {"type": "array", "items": {"type": "string"}}
}
} }
} }
}, },
@ -93,10 +137,54 @@
"Dashboard Management" "Dashboard Management"
], ],
"summary": "Get management console data", "summary": "Get management console data",
"description": "Requires a role that grants console.read. Each tenant and user entry carries the capability names its role grants.",
"security": [{"session": []}], "security": [{"session": []}],
"responses": {"200": {"description": "Dashboard data"}} "responses": {"200": {"description": "Dashboard data"}}
} }
}, },
"/profile": {
"get": {
"tags": ["Profile"],
"summary": "Open the self-service profile screen",
"description": "Available to every authenticated management session, including self-service User members.",
"security": [{"session": []}],
"responses": {"200": {"description": "Profile screen"}, "303": {"description": "Redirect to login"}}
}
},
"/api/v1/profile": {
"get": {
"tags": ["Profile"],
"summary": "Read the caller's own profile and tenant roles",
"security": [{"session": []}],
"responses": {
"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
"401": {"description": "Authentication required"}
}
},
"put": {
"tags": ["Profile"],
"summary": "Update the caller's display name or profile picture",
"security": [{"session": []}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdate"}}}},
"responses": {
"200": {"description": "Updated profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
"400": {"description": "Invalid display name or unsupported picture"}
}
}
},
"/api/v1/avatars/{key}": {
"get": {
"tags": ["Profile"],
"summary": "Fetch a profile picture",
"description": "Public on purpose so other applications can embed the picture with a plain image tag. The key is 256 bits of randomness and exposes nothing about the user.",
"parameters": [{"in": "path", "name": "key", "required": true, "schema": {"type": "string"}}],
"responses": {
"200": {"description": "Image bytes", "content": {"image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/png": {"schema": {"type": "string", "format": "binary"}}, "image/gif": {"schema": {"type": "string", "format": "binary"}}}},
"304": {"description": "Not modified"},
"404": {"description": "No picture for that key"}
}
}
},
"/api/v1/tenants": { "/api/v1/tenants": {
"post": { "post": {
"tags": [ "tags": [
@ -169,6 +257,7 @@
"User Action" "User Action"
], ],
"summary": "Exchange a one-time authorization code", "summary": "Exchange a one-time authorization code",
"description": "The nested user object carries role, permissions, and avatar_url for the tenant owning the application.",
"security": [{"clientBasic": []}], "security": [{"clientBasic": []}],
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}, "requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}},
"responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}} "responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}}
@ -179,7 +268,8 @@
"tags": [ "tags": [
"User Action" "User Action"
], ],
"summary": "Validate a client user session", "summary": "Validate a client user session and read the caller's role and picture",
"description": "Returns id, email, display_name, avatar_url, tenant and application identity, role, and permissions.",
"security": [{"userBearer": []}], "security": [{"userBearer": []}],
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}} "responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
} }
@ -210,7 +300,7 @@
"tags": [ "tags": [
"User Management" "User Management"
], ],
"summary": "Invite a tenant user", "summary": "Invite a tenant user with an admin, developer, operator, viewer, or user role",
"security": [{"session": []}], "security": [{"session": []}],
"responses": {"201": {"description": "User invited"}} "responses": {"201": {"description": "User invited"}}
} }
@ -221,11 +311,20 @@
"User Management" "User Management"
], ],
"summary": "Set tenant role and application access", "summary": "Set tenant role and application access",
"description": "Any known role is accepted, including owner.",
"security": [{"session": []}], "security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}], "parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "Access updated"}} "responses": {"204": {"description": "Access updated"}}
} }
}, },
"/api/v1/roles": {
"get": {
"tags": ["User Management"],
"summary": "List tenant roles with their capabilities",
"security": [{"session": []}],
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/RoleCatalogEntry"}}}}}}
}
},
"/api/v1/users/{id}/approve": { "/api/v1/users/{id}/approve": {
"post": { "post": {
"tags": ["User Management"], "tags": ["User Management"],

79
web/profile.html Normal file
View file

@ -0,0 +1,79 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Go Loose · Profile</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="profile-page">
<div class="noise"></div>
<header>
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav>
<a href="/">Console</a>
<a href="/docs">API docs</a>
<label class="compact">Theme
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
</label>
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
</nav>
</header>
<main>
<section class="hero profile-hero">
<div>
<p class="eyebrow">YOUR ACCOUNT</p>
<h1>Profile<span>.</span></h1>
<p class="lede">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.</p>
</div>
</section>
<div id="notice" class="notice" hidden></div>
<section class="profile-grid">
<article class="card profile-card">
<h2>Profile picture</h2>
<div class="avatar-preview">
<img id="avatar-image" alt="" width="112" height="112" hidden>
<span id="avatar-fallback" class="avatar-initials">–</span>
</div>
<form id="avatar-form">
<label>Choose a picture <small>(JPEG, PNG, or GIF, up to 2 MiB)</small><input type="file" id="avatar-file" accept="image/jpeg,image/png,image/gif"></label>
<div class="profile-actions">
<button type="submit" id="avatar-save" disabled>Upload picture</button>
<button type="button" class="ghost" id="avatar-remove" hidden>Remove picture</button>
</div>
</form>
<label class="muted">Picture link <small>(embed it with a plain image tag)</small>
<div class="secret"><code id="avatar-url">No picture uploaded</code><button id="copy-avatar" type="button" hidden>Copy</button></div>
</label>
</article>
<article class="card profile-card">
<h2>Account</h2>
<form id="profile-form">
<label>Display name<input name="display_name" id="display-name" maxlength="200" required></label>
<label>Email<input id="email" readonly></label>
<dl class="facts">
<div><dt>System administrator</dt><dd id="system-admin">—</dd></div>
<div><dt>Member since</dt><dd id="created-at">—</dd></div>
<div><dt>Last login</dt><dd id="last-login">—</dd></div>
<div><dt>Name managed by</dt><dd id="name-source">—</dd></div>
</dl>
<button type="submit">Save profile</button>
</form>
</article>
<article class="card profile-card profile-wide">
<h2>Tenant roles</h2>
<div class="table-wrap"><table><thead><tr><th>Tenant</th><th>Slug</th><th>Role</th><th>Capabilities</th></tr></thead><tbody id="membership-list"></tbody></table></div>
</article>
</section>
</main>
<script src="/assets/profile.js" defer></script>
</body>
</html>

166
web/profile.js Normal file
View file

@ -0,0 +1,166 @@
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
const date = value => value ? new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(value)) : 'Never';
const notice = (message, error = false) => {
const el = $('#notice');
el.textContent = message;
el.hidden = false;
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
clearTimeout(notice.timer);
notice.timer = setTimeout(() => el.hidden = true, 5000)
};
const avatar = {dataURL: '', fileName: '', pending: false};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin', ...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})}
});
if (response.status === 401) {
location.href = '/login';
throw new Error('Session expired')
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function load() {
render(await api('/api/v1/profile'))
}
function render(profile) {
$('#display-name').value = profile.display_name;
$('#email').value = profile.email;
$('#system-admin').textContent = profile.is_system_administrator ? 'Yes' : 'No';
$('#created-at').textContent = date(profile.created_at);
$('#last-login').textContent = date(profile.last_login_at);
$('#name-source').textContent = profile.name_customized ? 'You' : 'Your identity provider';
renderAvatar(profile);
$('#membership-list').innerHTML = profile.memberships.map(item => `<tr>
<td>${escapeHTML(item.tenant_name)}</td>
<td><code>${escapeHTML(item.tenant_slug)}</code></td>
<td><span class="badge">${escapeHTML(item.role)}</span></td>
<td class="capabilities">${item.permissions.map(permission => `<span class="chip">${escapeHTML(permission)}</span>`).join('') || '<span class="muted">None</span>'}</td>
</tr>`).join('') || '<tr><td colspan="4">No tenant role assigned yet</td></tr>'
}
function renderAvatar(profile) {
const image = $('#avatar-image'), fallback = $('#avatar-fallback'), link = $('#avatar-url');
const source = avatar.pending ? avatar.dataURL : profile.avatar_url;
$('#avatar-remove').hidden = !profile.avatar_url;
$('#copy-avatar').hidden = !profile.avatar_url;
if (source) {
image.src = source;
image.hidden = false;
fallback.hidden = true
} else {
image.hidden = true;
image.removeAttribute('src');
fallback.hidden = false;
fallback.textContent = initials(profile.display_name || profile.email)
}
link.textContent = profile.avatar_url || (avatar.pending ? 'Saved on upload' : 'No picture uploaded')
}
function initials(name) {
return String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?'
}
$('#profile-form').addEventListener('submit', async event => {
event.preventDefault();
try {
render(await api('/api/v1/profile', {
method: 'PUT',
body: JSON.stringify({display_name: $('#display-name').value})
}));
notice('Profile saved')
} catch (error) {
notice(error.message, true)
}
});
$('#avatar-file').addEventListener('change', async event => {
const file = event.target.files?.[0];
avatar.pending = false;
if (!file) {
$('#avatar-save').disabled = true;
return
}
if (file.size > 2 * 1024 * 1024) {
notice('The picture must be 2 MiB or smaller', true);
event.target.value = '';
$('#avatar-save').disabled = true;
return
}
avatar.dataURL = await readAsDataURL(file);
avatar.fileName = file.name;
avatar.pending = true;
$('#avatar-save').disabled = false;
renderAvatar({avatar_url: '', display_name: $('#display-name').value, email: $('#email').value})
});
$('#avatar-form').addEventListener('submit', async event => {
event.preventDefault();
if (!avatar.pending) return;
try {
const profile = await api('/api/v1/profile', {
method: 'PUT', body: JSON.stringify({avatar: avatar.dataURL})
});
avatar.pending = false;
$('#avatar-file').value = '';
$('#avatar-save').disabled = true;
render(profile);
notice('Profile picture updated')
} catch (error) {
notice(error.message, true)
}
});
$('#avatar-remove').addEventListener('click', async () => {
if (!confirm('Remove your profile picture?')) return;
try {
const profile = await api('/api/v1/profile', {method: 'PUT', body: JSON.stringify({remove_avatar: true})});
avatar.pending = false;
$('#avatar-file').value = '';
$('#avatar-save').disabled = true;
render(profile);
notice('Profile picture removed')
} catch (error) {
notice(error.message, true)
}
});
$('#copy-avatar').addEventListener('click', () => navigator.clipboard.writeText($('#avatar-url').textContent).then(() => notice('Picture link copied')));
function readAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('could not read the selected file'));
reader.readAsDataURL(file)
})
}
const theme = $('#theme');
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
document.documentElement.dataset.theme = theme.value;
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
theme.addEventListener('change', () => {
document.documentElement.dataset.theme = theme.value;
localStorage.setItem('gl-theme', theme.value)
});
$('#mode').addEventListener('click', () => {
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.mode = next;
localStorage.setItem('gl-mode', next)
});
load().catch(error => notice(error.message, true));

View file

@ -16,3 +16,4 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border
.password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em} .password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}
.installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)} .installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)}
@media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}} @media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}}
.identity{display:inline-flex;align-items:center;gap:8px;text-decoration:none}.identity img,.avatar{border-radius:50%;object-fit:cover;display:block;flex:none}.profile-hero{min-height:0;padding:56px 0 34px}.profile-hero h1{font-size:clamp(40px,5vw,64px)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin-top:34px}.profile-card{display:grid;gap:16px;align-content:start}.profile-card h2{margin:0;font-size:20px;letter-spacing:-.02em}.profile-wide{grid-column:1/-1}.profile-card form{display:grid;gap:14px}.profile-card label{display:grid;gap:7px;font-size:13px;color:var(--muted)}.profile-actions{display:flex;gap:10px;flex-wrap:wrap}.profile-actions button{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions .ghost{background:transparent;border:1px solid var(--border);color:var(--text)}.profile-card button[type=submit]{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer;justify-self:start}.avatar-preview{display:grid;place-items:center;width:112px;height:112px;border-radius:50%;overflow:hidden;background:var(--surface2);border:1px solid var(--border)}.avatar-preview img{width:100%;height:100%;object-fit:cover}.avatar-initials{font:750 34px ui-monospace,monospace;color:var(--accent)}.avatar-empty{display:inline-grid;place-items:center;width:28px;height:28px;vertical-align:middle;background:var(--surface2);border:1px solid var(--border);color:var(--accent);font:700 11px ui-monospace,monospace}.facts{display:grid;gap:10px;margin:4px 0 0}.facts div{display:flex;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);padding-bottom:8px;font-size:13px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right}.capabilities{display:flex;flex-wrap:wrap;gap:6px}.chip{font:600 11px ui-monospace,monospace;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 6px}