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
google_secrets.json
data/

View file

@ -12,7 +12,7 @@ GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true
export
.PHONY: build test run compose-up compose-down fmt vet
.PHONY: build test integration-test run compose-up compose-down fmt vet
build:
go build ./...
@ -20,6 +20,12 @@ build:
test:
go test -race ./...
# Integration tests truncate every table in the target database, so point them at
# a scratch database rather than one holding real data.
integration-test:
@test -n "$(GO_LOOSE_TEST_DATABASE_URL)" || { echo "set GO_LOOSE_TEST_DATABASE_URL to a scratch database"; exit 1; }
go test -race ./internal/store/ -count=1
run:
go run ./cmd/go-loose

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)
- [Go middleware integration](docs/integration.md)
- [Client application login](docs/client-login.md)
- [Tenant roles, capabilities, and profile pictures](docs/roles.md)
- [Database user authentication and local credentials](docs/password-authentication.md)
- [Operations, migrations, and OIDC](docs/operations.md)
- [Coding standards](docs/coding-standards.md)
## Profile
Every management session can open `/profile` to set a display name and a profile picture. Pictures are stored in PostgreSQL and served from a public, unguessable URL that client applications can embed directly. Display names stop being overwritten by the identity provider once the user customizes them. See [roles and capabilities](docs/roles.md).
## Development
```bash
@ -77,3 +82,6 @@ make build
Development requires Go 1.27.1 or newer.
Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one.
`make integration-test` runs the store integration tests against `GO_LOOSE_TEST_DATABASE_URL`. Those tests drop every table in the target database, so point them at a scratch database.

View file

@ -41,10 +41,28 @@ type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
// Role is the user's membership role in the owning tenant, for example
// user, operator, developer, admin, or owner.
Role string `json:"role"`
// Permissions lists the capability names granted by Role, such as
// "console.read", "keys.manage", or "profile.edit". Gate application
// features on these instead of comparing role names.
Permissions []string `json:"permissions"`
}
// Can reports whether the role behind this session grants a capability.
func (u User) Can(permission string) bool {
for _, granted := range u.Permissions {
if granted == permission {
return true
}
}
return false
}
type userContextKey struct{}

View file

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

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.
## Role and profile picture
Both `/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application:
```json
{
"sub": "9f0f...",
"email": "ada@example.com",
"display_name": "Ada Lovelace",
"avatar_url": "https://loose.example.com/api/v1/avatars/kZ3...",
"role": "developer",
"permissions": ["console.read", "profile.edit", "applications.manage", "keys.manage", "contracts.manage"],
"can": {"contracts.manage": true, "users.manage": false}
}
```
`avatar_url` is public by design, so it can be embedded directly with a plain image tag. It is an empty string when the user has not uploaded a picture. Read `can` rather than checking `role` by name; the client package builds it from the same matrix the console uses. See [roles and capabilities](roles.md).
A user with no membership in the application's tenant still authenticates, but the role falls back to `user` with only `profile.edit`.
## Security and availability
- Use TLS for both Go Loose and the client application.

View file

@ -8,6 +8,7 @@
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
- Return explicit errors. Runtime authorization must fail closed.
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
- Derive authorization from the matrix in `internal/role` instead of hardcoding role names. Pass `role.Grants(...)` into SQL, use `role.Allows` in handlers, and render controls from the capability lists the server returns.
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.

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"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// clientUserResponse is the identity payload handed to client applications. It
// carries the tenant role and its capabilities so that an application such as
// go-tell can gate features, plus an absolute avatar_url it can render directly.
type clientUserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
return clientUserResponse{
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: role.Role(user.Role).Permissions(),
}
}
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
@ -99,7 +125,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
})
}
@ -118,7 +144,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "load client user", err)
return
}
writeJSON(w, http.StatusOK, user)
writeJSON(w, http.StatusOK, s.clientUser(user))
}
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {

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/contract"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
@ -107,6 +108,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /auth/logout", s.authLogout)
mux.HandleFunc("POST /auth/password", s.passwordLogin)
mux.HandleFunc("GET /assets/{name}", s.asset)
mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar)
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
mux.HandleFunc("POST /connect/token", s.clientToken)
@ -117,6 +119,9 @@ func (s *Server) Handler() http.Handler {
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
// browser favicon request would redirect to /install and rotate its CSRF cookie.
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile))))
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
@ -155,16 +160,26 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
claims := claimsFrom(r)
console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
return
}
if !allowed {
s.serveEmbedded(w, "waiting-room.html")
s.internalError(w, "check console access", err)
return
}
if console {
s.serveEmbedded(w, "index.html")
return
}
member, err := s.store.HasMembership(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check membership", err)
return
}
if member {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
s.serveEmbedded(w, "waiting-room.html")
}
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
@ -177,7 +192,7 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" {
if name != "style.css" && name != "app.js" && name != "profile.js" {
http.NotFound(w, r)
return
}
@ -330,13 +345,13 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
s.internalError(w, "check console access", err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "waiting for administrator approval")
writeError(w, http.StatusForbidden, "this account has no console access")
return
}
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
@ -374,9 +389,19 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "check system administrator", err)
return
}
profile, err := s.store.Profile(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "load profile", err)
return
}
for index := range users {
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "roles": role.Catalog(),
"is_system_administrator": isSystemAdministrator,
})
}
@ -703,7 +728,11 @@ func validSlug(value string) bool {
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
return decodeJSONLimit(w, r, 6<<20, target)
}
func decodeJSONLimit(w http.ResponseWriter, r *http.Request, limit int64, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {

View file

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

View file

@ -8,8 +8,28 @@ import (
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/password"
"github.com/danyel/go-loose/internal/role"
)
// assignableRole validates a role that an administrator may hand to an existing
// member. Any known role qualifies, including owner, so that ownership can move
// between accounts deliberately.
func assignableRole(value string) (string, bool) {
parsed, ok := role.Parse(value)
return string(parsed), ok
}
// invitableRole validates a role for a new invitation. Ownership is established
// by creating or claiming a tenant, so it is never granted here.
func invitableRole(value string) (string, bool) {
for _, item := range role.Grantable() {
if string(item) == value {
return value, true
}
}
return "", false
}
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
var request struct {
RedirectURIs []string `json:"redirect_uris"`
@ -75,8 +95,9 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
if request.DisplayName == "" {
request.DisplayName = request.Email
}
if request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
invitedRole, ok := invitableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", "))
return
}
passwordHash, err := password.Hash(request.Password)
@ -84,11 +105,12 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, invitedRole, passwordHash)
if err != nil {
s.handleStoreError(w, "invite user", err)
return
}
user.AvatarURL = s.avatarURL(user.AvatarKey)
writeJSON(w, http.StatusCreated, user)
}
@ -101,11 +123,12 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil {
s.handleStoreError(w, "update user access", err)
return
}
@ -121,13 +144,14 @@ func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
membershipRole, ok := assignableRole(request.Role)
if !ok {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
request.Role, request.ApplicationIDs,
membershipRole, request.ApplicationIDs,
); err != nil {
s.handleStoreError(w, "approve pending user", err)
return

View file

@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"time"
"github.com/danyel/go-loose/internal/role"
)
type ManagedUser struct {
@ -14,8 +16,13 @@ type ManagedUser struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"`
// AvatarKey is the stored locator. The server turns it into the absolute
// AvatarURL returned to clients.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
}
type ClientApplication struct {
@ -36,12 +43,21 @@ type ClientUser struct {
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
// Role is the membership role in the tenant owning the application. A user
// without a membership is reported as User so clients always see the least
// privileged role.
Role string `json:"role"`
Permissions []string `json:"permissions"`
// AvatarKey is the stored locator; the server expands it into AvatarURL.
AvatarKey *string `json:"-"`
AvatarURL string `json:"avatar_url"`
}
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
u.avatar_key,
COALESCE((
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
FROM user_application_access ua
@ -53,9 +69,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
WHERE EXISTS (
SELECT 1 FROM memberships own
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
AND own.role IN ('owner', 'admin')
AND own.role = ANY($2)
)
ORDER BY u.email, m.tenant_id`, administratorID)
ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers))
if err != nil {
return nil, err
}
@ -64,18 +80,20 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
for rows.Next() {
var user ManagedUser
var applicationIDs []byte
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil {
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
&user.Status, &user.AvatarKey, &applicationIDs); err != nil {
return nil, err
}
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
return nil, err
}
user.Permissions = role.Role(user.Role).Permissions()
result = append(result, user)
}
return result, rows.Err()
}
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, membershipRole, passwordHash string) (ManagedUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ManagedUser{}, err
@ -85,8 +103,8 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
)`, tenantID, administratorID).Scan(&permitted); err != nil {
WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3)
)`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil {
return ManagedUser{}, err
}
if !permitted {
@ -109,17 +127,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, user.ID, role); err != nil {
tenantID, user.ID, membershipRole); err != nil {
return ManagedUser{}, err
}
if err := tx.Commit(); err != nil {
return ManagedUser{}, err
}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, membershipRole, []string{}
user.Permissions = role.Role(membershipRole).Permissions()
return user, nil
}
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
@ -130,8 +149,8 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
FROM memberships administrator
WHERE target.tenant_id = $1 AND target.user_id = $2
AND administrator.tenant_id = target.tenant_id
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`,
tenantID, userID, administratorID, role)
AND administrator.user_id = $3 AND administrator.role = ANY($5)`,
tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers))
if err != nil {
return err
}
@ -165,12 +184,12 @@ func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicatio
var encoded []byte
err := s.db.QueryRowContext(ctx, `
UPDATE applications a
SET redirect_uris = $3, client_secret_hash = $4, updated_at = now()
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
FROM memberships m
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
AND m.role IN ('owner', 'admin')
AND m.role = ANY($3)
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
applicationID, administratorID, redirectURIs, secretHash).
applicationID, administratorID, role.Grants(role.ManageApplications), redirectURIs, secretHash).
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound
@ -246,14 +265,20 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
var user ClientUser
err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now()
FROM applications a, tenants t, users u
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
AND c.redirect_uri = $2 AND a.id = c.application_id
AND ua.user_id = c.user_id AND ua.application_id = a.id
AND a.client_id = $3 AND a.client_secret_hash = $4
AND t.id = a.tenant_id AND u.id = c.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
codeHash, redirectURI, clientID, secretHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(m.role, $5), u.avatar_key`,
codeHash, redirectURI, clientID, secretHash, role.User).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound
}
@ -273,12 +298,18 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client
var user ClientUser
err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now()
FROM applications a, tenants t, users u, user_application_access ua
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id
AND ua.application_id = a.id AND ua.user_id = u.id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
AND a.id = s.application_id AND ua.user_id = s.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(m.role, $2), u.avatar_key`,
tokenHash, role.User).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound
}

View file

@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"github.com/danyel/go-loose/internal/role"
)
type Installation struct {
@ -78,8 +80,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil {
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role.Admin); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
@ -113,7 +116,7 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil {
SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
@ -124,11 +127,24 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
return true, tx.Commit()
}
func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) {
// HasConsoleAccess reports whether the user may read the management console.
// System administrators always qualify; otherwise the membership role must
// grant console.read, which excludes self-service User members.
func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed)
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1 AND role = ANY($2))`,
userID, role.Grants(role.ReadConsole)).Scan(&allowed)
return allowed, err
}
// HasMembership reports whether the user belongs to any tenant. A member without
// console access is a self-service user rather than a waiting-room visitor.
func (s *Store) HasMembership(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).
Scan(&allowed)
return allowed, err
}

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"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/role"
)
var ErrNotFound = errors.New("not found")
@ -29,6 +30,9 @@ type Tenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
Role string `json:"role"`
// Permissions is derived from Role so that the console and API clients read
// capabilities instead of re-implementing the role matrix.
Permissions []string `json:"permissions"`
}
type Application struct {
@ -86,8 +90,15 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri
return User{}, err
}
defer tx.Rollback()
// The identity provider owns the display name until the user customizes it
// in their profile. The row must still match on every login, otherwise the
// insert below would collide with the unique email index.
err = tx.QueryRowContext(ctx, `
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
UPDATE users
SET subject = $1,
email = lower($2),
display_name = CASE WHEN name_customized THEN display_name ELSE $3 END,
last_login_at = now()
WHERE lower(email) = lower($2)
RETURNING id, email, display_name`, subject, email, displayName).
Scan(&user.ID, &user.Email, &user.DisplayName)
@ -120,10 +131,10 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT $1, $2, 'owner'
SELECT $1, $2, $3
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
ON CONFLICT DO NOTHING`, tenantID, userID, role.Owner); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `
@ -161,6 +172,7 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
return nil, err
}
item.Permissions = role.Role(item.Role).Permissions()
result = append(result, item)
}
return result, rows.Err()
@ -181,7 +193,8 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
return Tenant{}, err
}
tenant.Role = "owner"
tenant.Role = string(role.Owner)
tenant.Permissions = role.Owner.Permissions()
if err := tx.Commit(); err != nil {
return Tenant{}, err
}
@ -225,11 +238,13 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (
SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin')
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $6 AND role = ANY($7)
)
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
client_id, to_json(redirect_uris), false`,
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID)
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID,
role.Grants(role.ManageApplications))
var allowedHosts, redirectURIs []byte
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
@ -273,9 +288,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin')
WHERE a.id = $1 AND m.user_id = $6 AND m.role = ANY($7)
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
applicationID, name, prefix, hash, expiresAt, userID).
applicationID, name, prefix, hash, expiresAt, userID, role.Grants(role.ManageKeys)).
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound
@ -287,8 +302,8 @@ func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
FROM memberships m
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`,
keyID, userID)
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`,
keyID, userID, role.Grants(role.ManageKeys))
if err != nil {
return err
}
@ -349,9 +364,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
SELECT a.id, $2, $3, $4, $5
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin')
WHERE a.id = $1 AND m.user_id = $5 AND m.role = ANY($6)
RETURNING id, application_id, version, source_url, created_at`,
applicationID, document.Version, sourceURL, document.JSON, userID).
applicationID, document.Version, sourceURL, document.JSON, userID,
role.Grants(role.ManageContracts)).
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Contract{}, ErrNotFound

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 roleOptions = roles => roles
.map(item => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
.join('');
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
@ -38,14 +48,38 @@ async function load() {
const data = await api('/api/v1/dashboard');
Object.assign(state, data);
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
$('#identity').textContent = data.user.display_name || data.user.email;
renderIdentity();
renderRoleOptions();
render();
}
function renderIdentity() {
const profile = state.profile || {};
$('#identity-name').textContent = profile.display_name || profile.email || '';
const image = $('#identity-avatar');
if (profile.avatar_url) {
image.src = profile.avatar_url;
image.hidden = false
} else {
image.hidden = true;
image.removeAttribute('src')
}
}
function renderRoleOptions() {
// Inviting an owner is rejected by the server, so leave it out of the list.
const invitable = state.roles.filter(item => item.value !== 'owner');
$('#invite-role').innerHTML = roleOptions(invitable);
$('#access-role').innerHTML = roleOptions(state.roles);
}
function render() {
const tenantID = state.selectedTenant;
const selectedTenant = state.tenants.find(item => item.id === tenantID);
const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role);
const canManageApplications = grants(selectedTenant, 'applications.manage');
const canManageKeys = grants(selectedTenant, 'keys.manage');
const canManageContracts = grants(selectedTenant, 'contracts.manage');
const canManageUsers = grants(selectedTenant, 'users.manage');
const tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
const tenantAppIDs = new Set(tenantApps.map(item => item.id));
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id));
@ -60,12 +94,15 @@ function render() {
$('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
$('#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');
$('#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');
$('#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');
$('#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>${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');
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');
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant);
$('#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-application-action]').forEach(element => element.hidden = !canManageApplications);
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
$$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers);
$$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
select.value = tenantID;
@ -74,6 +111,10 @@ function render() {
}
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>`;
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>
</label>
<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>
</nav>
</header>
@ -58,15 +58,15 @@
<div class="panels">
<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>
</section>
<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>
</section>
<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>
</section>
<section class="panel" id="tenants">
@ -74,8 +74,9 @@
<div class="card-grid" id="tenant-list"></div>
</section>
<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>
<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>
</div>
</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="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="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="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="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" 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>
<script src="/assets/app.js" defer></script>

View file

@ -4,7 +4,7 @@
<body class="login-page">
<div class="login-glow"></div>
<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">
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
@ -13,7 +13,7 @@
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<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>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
@ -23,7 +23,7 @@
</form>
<div style="{{SSO_STYLE}}">
<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>
</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>

View file

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

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