fix installer
This commit is contained in:
parent
318fb51085
commit
6319da650c
31 changed files with 2391 additions and 87 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ bin/
|
||||||
coverage.out
|
coverage.out
|
||||||
google_secrets.json
|
google_secrets.json
|
||||||
|
|
||||||
|
data/
|
||||||
8
Makefile
8
Makefile
|
|
@ -12,7 +12,7 @@ GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true
|
||||||
|
|
||||||
export
|
export
|
||||||
|
|
||||||
.PHONY: build test run compose-up compose-down fmt vet
|
.PHONY: build test integration-test run compose-up compose-down fmt vet
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build ./...
|
go build ./...
|
||||||
|
|
@ -20,6 +20,12 @@ build:
|
||||||
test:
|
test:
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
|
# Integration tests truncate every table in the target database, so point them at
|
||||||
|
# a scratch database rather than one holding real data.
|
||||||
|
integration-test:
|
||||||
|
@test -n "$(GO_LOOSE_TEST_DATABASE_URL)" || { echo "set GO_LOOSE_TEST_DATABASE_URL to a scratch database"; exit 1; }
|
||||||
|
go test -race ./internal/store/ -count=1
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run ./cmd/go-loose
|
go run ./cmd/go-loose
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,15 @@ Production must place Go Loose behind TLS, use a secret manager, set `GO_LOOSE_B
|
||||||
- [First-run installation, waiting room, and demo accounts](docs/installation.md)
|
- [First-run installation, waiting room, and demo accounts](docs/installation.md)
|
||||||
- [Go middleware integration](docs/integration.md)
|
- [Go middleware integration](docs/integration.md)
|
||||||
- [Client application login](docs/client-login.md)
|
- [Client application login](docs/client-login.md)
|
||||||
|
- [Tenant roles, capabilities, and profile pictures](docs/roles.md)
|
||||||
- [Database user authentication and local credentials](docs/password-authentication.md)
|
- [Database user authentication and local credentials](docs/password-authentication.md)
|
||||||
- [Operations, migrations, and OIDC](docs/operations.md)
|
- [Operations, migrations, and OIDC](docs/operations.md)
|
||||||
- [Coding standards](docs/coding-standards.md)
|
- [Coding standards](docs/coding-standards.md)
|
||||||
|
|
||||||
|
## Profile
|
||||||
|
|
||||||
|
Every management session can open `/profile` to set a display name and a profile picture. Pictures are stored in PostgreSQL and served from a public, unguessable URL that client applications can embed directly. Display names stop being overwritten by the identity provider once the user customizes them. See [roles and capabilities](docs/roles.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -77,3 +82,6 @@ make build
|
||||||
Development requires Go 1.27.1 or newer.
|
Development requires Go 1.27.1 or newer.
|
||||||
|
|
||||||
Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one.
|
Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one.
|
||||||
|
|
||||||
|
`make integration-test` runs the store integration tests against `GO_LOOSE_TEST_DATABASE_URL`. Those tests drop every table in the target database, so point them at a scratch database.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,28 @@ type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
TenantSlug string `json:"tenant_slug"`
|
TenantSlug string `json:"tenant_slug"`
|
||||||
ApplicationID string `json:"application_id"`
|
ApplicationID string `json:"application_id"`
|
||||||
Application string `json:"application"`
|
Application string `json:"application"`
|
||||||
|
// Role is the user's membership role in the owning tenant, for example
|
||||||
|
// user, operator, developer, admin, or owner.
|
||||||
|
Role string `json:"role"`
|
||||||
|
// Permissions lists the capability names granted by Role, such as
|
||||||
|
// "console.read", "keys.manage", or "profile.edit". Gate application
|
||||||
|
// features on these instead of comparing role names.
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can reports whether the role behind this session grants a capability.
|
||||||
|
func (u User) Can(permission string) bool {
|
||||||
|
for _, granted := range u.Permissions {
|
||||||
|
if granted == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type userContextKey struct{}
|
type userContextKey struct{}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ services:
|
||||||
POSTGRES_USER: goloose
|
POSTGRES_USER: goloose
|
||||||
POSTGRES_PASSWORD: goloose
|
POSTGRES_PASSWORD: goloose
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data2:/var/lib/postgresql/data
|
- ./data/db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
|
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|
@ -48,8 +48,6 @@ services:
|
||||||
aliases:
|
aliases:
|
||||||
- go-loose
|
- go-loose
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data2:
|
|
||||||
networks:
|
networks:
|
||||||
go_loose_network:
|
go_loose_network:
|
||||||
local_dev_edge:
|
local_dev_edge:
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,26 @@ Request → client middleware → GET /connect/userinfo → protected handler
|
||||||
|
|
||||||
The authorization code expires after two minutes and can be used once. Redirect URIs must match exactly. Removing a user's application grant immediately makes `/connect/userinfo` reject existing sessions. Logout revokes the central session and clears the local cookie.
|
The authorization code expires after two minutes and can be used once. Redirect URIs must match exactly. Removing a user's application grant immediately makes `/connect/userinfo` reject existing sessions. Logout revokes the central session and clears the local cookie.
|
||||||
|
|
||||||
|
## Role and profile picture
|
||||||
|
|
||||||
|
Both `/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "9f0f...",
|
||||||
|
"email": "ada@example.com",
|
||||||
|
"display_name": "Ada Lovelace",
|
||||||
|
"avatar_url": "https://loose.example.com/api/v1/avatars/kZ3...",
|
||||||
|
"role": "developer",
|
||||||
|
"permissions": ["console.read", "profile.edit", "applications.manage", "keys.manage", "contracts.manage"],
|
||||||
|
"can": {"contracts.manage": true, "users.manage": false}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`avatar_url` is public by design, so it can be embedded directly with a plain image tag. It is an empty string when the user has not uploaded a picture. Read `can` rather than checking `role` by name; the client package builds it from the same matrix the console uses. See [roles and capabilities](roles.md).
|
||||||
|
|
||||||
|
A user with no membership in the application's tenant still authenticates, but the role falls back to `user` with only `profile.edit`.
|
||||||
|
|
||||||
## Security and availability
|
## Security and availability
|
||||||
|
|
||||||
- Use TLS for both Go Loose and the client application.
|
- Use TLS for both Go Loose and the client application.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
|
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
|
||||||
- Return explicit errors. Runtime authorization must fail closed.
|
- Return explicit errors. Runtime authorization must fail closed.
|
||||||
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
|
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
|
||||||
|
- Derive authorization from the matrix in `internal/role` instead of hardcoding role names. Pass `role.Grants(...)` into SQL, use `role.Allows` in handlers, and render controls from the capability lists the server returns.
|
||||||
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
|
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
|
||||||
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.
|
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.
|
||||||
|
|
||||||
|
|
|
||||||
53
docs/roles.md
Normal file
53
docs/roles.md
Normal 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
143
internal/avatar/avatar.go
Normal 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 "", ""
|
||||||
|
}
|
||||||
172
internal/avatar/avatar_test.go
Normal file
172
internal/avatar/avatar_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/database/migrations/0005_roles_and_profile.sql
Normal file
45
internal/database/migrations/0005_roles_and_profile.sql
Normal 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
175
internal/role/role.go
Normal 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
135
internal/role/role_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,35 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danyel/go-loose/internal/key"
|
"github.com/danyel/go-loose/internal/key"
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
"github.com/danyel/go-loose/internal/store"
|
"github.com/danyel/go-loose/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// clientUserResponse is the identity payload handed to client applications. It
|
||||||
|
// carries the tenant role and its capabilities so that an application such as
|
||||||
|
// go-tell can gate features, plus an absolute avatar_url it can render directly.
|
||||||
|
type clientUserResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
TenantSlug string `json:"tenant_slug"`
|
||||||
|
ApplicationID string `json:"application_id"`
|
||||||
|
Application string `json:"application"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
|
||||||
|
return clientUserResponse{
|
||||||
|
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
|
||||||
|
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
|
||||||
|
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
|
||||||
|
Role: user.Role, Permissions: role.Role(user.Role).Permissions(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
|
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
|
||||||
|
|
@ -99,7 +125,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"access_token": token, "token_type": "Bearer",
|
"access_token": token, "token_type": "Bearer",
|
||||||
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
|
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +144,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
s.internalError(w, "load client user", err)
|
s.internalError(w, "load client user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, user)
|
writeJSON(w, http.StatusOK, s.clientUser(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
143
internal/server/profile.go
Normal file
143
internal/server/profile.go
Normal 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
|
||||||
|
}
|
||||||
194
internal/server/profile_test.go
Normal file
194
internal/server/profile_test.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/danyel/go-loose/internal/config"
|
"github.com/danyel/go-loose/internal/config"
|
||||||
"github.com/danyel/go-loose/internal/contract"
|
"github.com/danyel/go-loose/internal/contract"
|
||||||
"github.com/danyel/go-loose/internal/key"
|
"github.com/danyel/go-loose/internal/key"
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
"github.com/danyel/go-loose/internal/secretbox"
|
"github.com/danyel/go-loose/internal/secretbox"
|
||||||
"github.com/danyel/go-loose/internal/session"
|
"github.com/danyel/go-loose/internal/session"
|
||||||
"github.com/danyel/go-loose/internal/store"
|
"github.com/danyel/go-loose/internal/store"
|
||||||
|
|
@ -107,6 +108,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.HandleFunc("POST /auth/logout", s.authLogout)
|
mux.HandleFunc("POST /auth/logout", s.authLogout)
|
||||||
mux.HandleFunc("POST /auth/password", s.passwordLogin)
|
mux.HandleFunc("POST /auth/password", s.passwordLogin)
|
||||||
mux.HandleFunc("GET /assets/{name}", s.asset)
|
mux.HandleFunc("GET /assets/{name}", s.asset)
|
||||||
|
mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar)
|
||||||
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
|
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
|
||||||
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
|
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
|
||||||
mux.HandleFunc("POST /connect/token", s.clientToken)
|
mux.HandleFunc("POST /connect/token", s.clientToken)
|
||||||
|
|
@ -117,6 +119,9 @@ func (s *Server) Handler() http.Handler {
|
||||||
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
|
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
|
||||||
// browser favicon request would redirect to /install and rotate its CSRF cookie.
|
// browser favicon request would redirect to /install and rotate its CSRF cookie.
|
||||||
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
|
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
|
||||||
|
mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
|
||||||
|
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
|
||||||
|
mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile))))
|
||||||
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
|
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
|
||||||
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
|
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
|
||||||
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
|
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
|
||||||
|
|
@ -155,16 +160,26 @@ func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||||
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
|
claims := claimsFrom(r)
|
||||||
|
console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internalError(w, "check management access", err)
|
s.internalError(w, "check console access", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
s.serveEmbedded(w, "waiting-room.html")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if console {
|
||||||
s.serveEmbedded(w, "index.html")
|
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) {
|
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
@ -177,7 +192,7 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
||||||
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||||
name := path.Base(r.PathValue("name"))
|
name := path.Base(r.PathValue("name"))
|
||||||
if name != "style.css" && name != "app.js" {
|
if name != "style.css" && name != "app.js" && name != "profile.js" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -330,13 +345,13 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := claimsFrom(r)
|
claims := claimsFrom(r)
|
||||||
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
|
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internalError(w, "check management access", err)
|
s.internalError(w, "check console access", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !allowed {
|
if !allowed {
|
||||||
writeError(w, http.StatusForbidden, "waiting for administrator approval")
|
writeError(w, http.StatusForbidden, "this account has no console access")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
|
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
|
||||||
|
|
@ -374,9 +389,19 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
s.internalError(w, "check system administrator", err)
|
s.internalError(w, "check system administrator", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
profile, err := s.store.Profile(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, "load profile", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index := range users {
|
||||||
|
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
"user": claims, "profile": s.profileResponse(profile),
|
||||||
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
|
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
||||||
|
"users": users, "pending_users": pendingUsers, "roles": role.Catalog(),
|
||||||
|
"is_system_administrator": isSystemAdministrator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,7 +728,11 @@ func validSlug(value string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
|
return decodeJSONLimit(w, r, 6<<20, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONLimit(w http.ResponseWriter, r *http.Request, limit int64, target any) bool {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
decoder := json.NewDecoder(r.Body)
|
decoder := json.NewDecoder(r.Body)
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(target); err != nil {
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -138,12 +138,14 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
tenantSlug := s.requestTenantSlug(r.Host)
|
tenantSlug := s.requestTenantSlug(r.Host)
|
||||||
tenantLogin := tenantSlug != ""
|
tenantLogin := tenantSlug != ""
|
||||||
|
loginPrompt := "admin@goloose:~"
|
||||||
loginContext := "SYSTEM ADMINISTRATION"
|
loginContext := "SYSTEM ADMINISTRATION"
|
||||||
loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant."
|
loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant."
|
||||||
passwordButton := "LOGIN"
|
passwordButton := "LOGIN"
|
||||||
if tenantLogin {
|
if tenantLogin {
|
||||||
loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION"
|
loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION"
|
||||||
loginMessage = "Sign in with your tenant database account."
|
loginMessage = "Sign in with your tenant database account."
|
||||||
|
loginPrompt = tenantSlug + "@goloose:~"
|
||||||
passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug)
|
passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug)
|
||||||
}
|
}
|
||||||
page := strings.NewReplacer(
|
page := strings.NewReplacer(
|
||||||
|
|
@ -153,6 +155,7 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
"{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton),
|
"{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton),
|
||||||
"{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin),
|
"{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin),
|
||||||
"{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()),
|
"{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()),
|
||||||
|
"{{LOGIN_PROMPT}}", loginPrompt,
|
||||||
).Replace(string(content))
|
).Replace(string(content))
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write([]byte(page))
|
w.Write([]byte(page))
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,28 @@ import (
|
||||||
|
|
||||||
"github.com/danyel/go-loose/internal/key"
|
"github.com/danyel/go-loose/internal/key"
|
||||||
"github.com/danyel/go-loose/internal/password"
|
"github.com/danyel/go-loose/internal/password"
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// assignableRole validates a role that an administrator may hand to an existing
|
||||||
|
// member. Any known role qualifies, including owner, so that ownership can move
|
||||||
|
// between accounts deliberately.
|
||||||
|
func assignableRole(value string) (string, bool) {
|
||||||
|
parsed, ok := role.Parse(value)
|
||||||
|
return string(parsed), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// invitableRole validates a role for a new invitation. Ownership is established
|
||||||
|
// by creating or claiming a tenant, so it is never granted here.
|
||||||
|
func invitableRole(value string) (string, bool) {
|
||||||
|
for _, item := range role.Grantable() {
|
||||||
|
if string(item) == value {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
|
||||||
var request struct {
|
var request struct {
|
||||||
RedirectURIs []string `json:"redirect_uris"`
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
|
@ -75,8 +95,9 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if request.DisplayName == "" {
|
if request.DisplayName == "" {
|
||||||
request.DisplayName = request.Email
|
request.DisplayName = request.Email
|
||||||
}
|
}
|
||||||
if request.Role != "admin" && request.Role != "viewer" {
|
invitedRole, ok := invitableRole(request.Role)
|
||||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "role must be one of "+strings.Join(role.GrantableValues(), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
passwordHash, err := password.Hash(request.Password)
|
passwordHash, err := password.Hash(request.Password)
|
||||||
|
|
@ -84,11 +105,12 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
|
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, invitedRole, passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.handleStoreError(w, "invite user", err)
|
s.handleStoreError(w, "invite user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
user.AvatarURL = s.avatarURL(user.AvatarKey)
|
||||||
writeJSON(w, http.StatusCreated, user)
|
writeJSON(w, http.StatusCreated, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,11 +123,12 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
|
||||||
if !decodeJSON(w, r, &request) {
|
if !decodeJSON(w, r, &request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
|
membershipRole, ok := assignableRole(request.Role)
|
||||||
|
if !ok {
|
||||||
writeError(w, http.StatusBadRequest, "invalid role")
|
writeError(w, http.StatusBadRequest, "invalid role")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
|
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil {
|
||||||
s.handleStoreError(w, "update user access", err)
|
s.handleStoreError(w, "update user access", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -121,13 +144,14 @@ func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if !decodeJSON(w, r, &request) {
|
if !decodeJSON(w, r, &request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
|
membershipRole, ok := assignableRole(request.Role)
|
||||||
|
if !ok {
|
||||||
writeError(w, http.StatusBadRequest, "invalid role")
|
writeError(w, http.StatusBadRequest, "invalid role")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.ApprovePendingUser(
|
if err := s.store.ApprovePendingUser(
|
||||||
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
|
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
|
||||||
request.Role, request.ApplicationIDs,
|
membershipRole, request.ApplicationIDs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
s.handleStoreError(w, "approve pending user", err)
|
s.handleStoreError(w, "approve pending user", err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ManagedUser struct {
|
type ManagedUser struct {
|
||||||
|
|
@ -14,8 +16,13 @@ type ManagedUser struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ApplicationIDs []string `json:"application_ids"`
|
ApplicationIDs []string `json:"application_ids"`
|
||||||
|
// AvatarKey is the stored locator. The server turns it into the absolute
|
||||||
|
// AvatarURL returned to clients.
|
||||||
|
AvatarKey *string `json:"-"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientApplication struct {
|
type ClientApplication struct {
|
||||||
|
|
@ -36,12 +43,21 @@ type ClientUser struct {
|
||||||
TenantSlug string `json:"tenant_slug"`
|
TenantSlug string `json:"tenant_slug"`
|
||||||
ApplicationID string `json:"application_id"`
|
ApplicationID string `json:"application_id"`
|
||||||
Application string `json:"application"`
|
Application string `json:"application"`
|
||||||
|
// Role is the membership role in the tenant owning the application. A user
|
||||||
|
// without a membership is reported as User so clients always see the least
|
||||||
|
// privileged role.
|
||||||
|
Role string `json:"role"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
// AvatarKey is the stored locator; the server expands it into AvatarURL.
|
||||||
|
AvatarKey *string `json:"-"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
|
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
|
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
|
||||||
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
|
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
|
||||||
|
u.avatar_key,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
|
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
|
||||||
FROM user_application_access ua
|
FROM user_application_access ua
|
||||||
|
|
@ -53,9 +69,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM memberships own
|
SELECT 1 FROM memberships own
|
||||||
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
|
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
|
||||||
AND own.role IN ('owner', 'admin')
|
AND own.role = ANY($2)
|
||||||
)
|
)
|
||||||
ORDER BY u.email, m.tenant_id`, administratorID)
|
ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -64,18 +80,20 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user ManagedUser
|
var user ManagedUser
|
||||||
var applicationIDs []byte
|
var applicationIDs []byte
|
||||||
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil {
|
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
|
||||||
|
&user.Status, &user.AvatarKey, &applicationIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
|
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
user.Permissions = role.Role(user.Role).Permissions()
|
||||||
result = append(result, user)
|
result = append(result, user)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
|
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, membershipRole, passwordHash string) (ManagedUser, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ManagedUser{}, err
|
return ManagedUser{}, err
|
||||||
|
|
@ -85,8 +103,8 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
|
||||||
if err := tx.QueryRowContext(ctx, `
|
if err := tx.QueryRowContext(ctx, `
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM memberships
|
SELECT 1 FROM memberships
|
||||||
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
|
WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3)
|
||||||
)`, tenantID, administratorID).Scan(&permitted); err != nil {
|
)`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil {
|
||||||
return ManagedUser{}, err
|
return ManagedUser{}, err
|
||||||
}
|
}
|
||||||
if !permitted {
|
if !permitted {
|
||||||
|
|
@ -109,17 +127,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
|
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||||
tenantID, user.ID, role); err != nil {
|
tenantID, user.ID, membershipRole); err != nil {
|
||||||
return ManagedUser{}, err
|
return ManagedUser{}, err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return ManagedUser{}, err
|
return ManagedUser{}, err
|
||||||
}
|
}
|
||||||
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
|
user.TenantID, user.Role, user.ApplicationIDs = tenantID, membershipRole, []string{}
|
||||||
|
user.Permissions = role.Role(membershipRole).Permissions()
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
|
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -130,8 +149,8 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
|
||||||
FROM memberships administrator
|
FROM memberships administrator
|
||||||
WHERE target.tenant_id = $1 AND target.user_id = $2
|
WHERE target.tenant_id = $1 AND target.user_id = $2
|
||||||
AND administrator.tenant_id = target.tenant_id
|
AND administrator.tenant_id = target.tenant_id
|
||||||
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`,
|
AND administrator.user_id = $3 AND administrator.role = ANY($5)`,
|
||||||
tenantID, userID, administratorID, role)
|
tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -165,12 +184,12 @@ func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicatio
|
||||||
var encoded []byte
|
var encoded []byte
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
UPDATE applications a
|
UPDATE applications a
|
||||||
SET redirect_uris = $3, client_secret_hash = $4, updated_at = now()
|
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
|
||||||
FROM memberships m
|
FROM memberships m
|
||||||
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
|
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
|
||||||
AND m.role IN ('owner', 'admin')
|
AND m.role = ANY($3)
|
||||||
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
|
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
|
||||||
applicationID, administratorID, redirectURIs, secretHash).
|
applicationID, administratorID, role.Grants(role.ManageApplications), redirectURIs, secretHash).
|
||||||
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
|
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Application{}, ErrNotFound
|
return Application{}, ErrNotFound
|
||||||
|
|
@ -246,14 +265,20 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
|
||||||
var user ClientUser
|
var user ClientUser
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
UPDATE client_authorization_codes c SET used_at = now()
|
UPDATE client_authorization_codes c SET used_at = now()
|
||||||
FROM applications a, tenants t, users u
|
FROM applications a
|
||||||
|
JOIN tenants t ON t.id = a.tenant_id
|
||||||
|
JOIN user_application_access ua ON ua.application_id = a.id
|
||||||
|
JOIN users u ON u.id = ua.user_id
|
||||||
|
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
|
||||||
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
|
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
|
||||||
AND c.redirect_uri = $2 AND a.id = c.application_id
|
AND c.redirect_uri = $2 AND a.id = c.application_id
|
||||||
|
AND ua.user_id = c.user_id AND ua.application_id = a.id
|
||||||
AND a.client_id = $3 AND a.client_secret_hash = $4
|
AND a.client_id = $3 AND a.client_secret_hash = $4
|
||||||
AND t.id = a.tenant_id AND u.id = c.user_id
|
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
|
||||||
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
|
COALESCE(m.role, $5), u.avatar_key`,
|
||||||
codeHash, redirectURI, clientID, secretHash).
|
codeHash, redirectURI, clientID, secretHash, role.User).
|
||||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
|
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
|
||||||
|
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ClientUser{}, time.Time{}, ErrNotFound
|
return ClientUser{}, time.Time{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
@ -273,12 +298,18 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client
|
||||||
var user ClientUser
|
var user ClientUser
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
UPDATE client_user_sessions s SET last_used_at = now()
|
UPDATE client_user_sessions s SET last_used_at = now()
|
||||||
FROM applications a, tenants t, users u, user_application_access ua
|
FROM applications a
|
||||||
|
JOIN tenants t ON t.id = a.tenant_id
|
||||||
|
JOIN user_application_access ua ON ua.application_id = a.id
|
||||||
|
JOIN users u ON u.id = ua.user_id
|
||||||
|
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
|
||||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
|
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
|
||||||
AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id
|
AND a.id = s.application_id AND ua.user_id = s.user_id
|
||||||
AND ua.application_id = a.id AND ua.user_id = u.id
|
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
|
||||||
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
|
COALESCE(m.role, $2), u.avatar_key`,
|
||||||
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
|
tokenHash, role.User).
|
||||||
|
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
|
||||||
|
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ClientUser{}, ErrNotFound
|
return ClientUser{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Installation struct {
|
type Installation struct {
|
||||||
|
|
@ -78,8 +80,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin')
|
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil {
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||||
|
tenantID, userID, role.Admin); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
|
@ -113,7 +116,7 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO memberships(tenant_id, user_id, role)
|
INSERT INTO memberships(tenant_id, user_id, role)
|
||||||
SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil {
|
SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
|
@ -124,11 +127,24 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
|
||||||
return true, tx.Commit()
|
return true, tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) {
|
// HasConsoleAccess reports whether the user may read the management console.
|
||||||
|
// System administrators always qualify; otherwise the membership role must
|
||||||
|
// grant console.read, which excludes self-service User members.
|
||||||
|
func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) {
|
||||||
var allowed bool
|
var allowed bool
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
|
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
|
||||||
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed)
|
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1 AND role = ANY($2))`,
|
||||||
|
userID, role.Grants(role.ReadConsole)).Scan(&allowed)
|
||||||
|
return allowed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasMembership reports whether the user belongs to any tenant. A member without
|
||||||
|
// console access is a self-service user rather than a waiting-room visitor.
|
||||||
|
func (s *Store) HasMembership(ctx context.Context, userID string) (bool, error) {
|
||||||
|
var allowed bool
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).
|
||||||
|
Scan(&allowed)
|
||||||
return allowed, err
|
return allowed, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
510
internal/store/integration_test.go
Normal file
510
internal/store/integration_test.go
Normal 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
150
internal/store/profile.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danyel/go-loose/internal/contract"
|
"github.com/danyel/go-loose/internal/contract"
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("not found")
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
@ -29,6 +30,9 @@ type Tenant struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
// Permissions is derived from Role so that the console and API clients read
|
||||||
|
// capabilities instead of re-implementing the role matrix.
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
|
|
@ -86,8 +90,15 @@ func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName stri
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
// The identity provider owns the display name until the user customizes it
|
||||||
|
// in their profile. The row must still match on every login, otherwise the
|
||||||
|
// insert below would collide with the unique email index.
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
|
UPDATE users
|
||||||
|
SET subject = $1,
|
||||||
|
email = lower($2),
|
||||||
|
display_name = CASE WHEN name_customized THEN display_name ELSE $3 END,
|
||||||
|
last_login_at = now()
|
||||||
WHERE lower(email) = lower($2)
|
WHERE lower(email) = lower($2)
|
||||||
RETURNING id, email, display_name`, subject, email, displayName).
|
RETURNING id, email, display_name`, subject, email, displayName).
|
||||||
Scan(&user.ID, &user.Email, &user.DisplayName)
|
Scan(&user.ID, &user.Email, &user.DisplayName)
|
||||||
|
|
@ -120,10 +131,10 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `
|
if _, err = tx.ExecContext(ctx, `
|
||||||
INSERT INTO memberships(tenant_id, user_id, role)
|
INSERT INTO memberships(tenant_id, user_id, role)
|
||||||
SELECT $1, $2, 'owner'
|
SELECT $1, $2, $3
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
|
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
|
||||||
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
|
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
|
||||||
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
|
ON CONFLICT DO NOTHING`, tenantID, userID, role.Owner); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `
|
if _, err = tx.ExecContext(ctx, `
|
||||||
|
|
@ -161,6 +172,7 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error
|
||||||
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
|
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
item.Permissions = role.Role(item.Role).Permissions()
|
||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
|
|
@ -181,7 +193,8 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
|
||||||
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
|
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
|
||||||
return Tenant{}, err
|
return Tenant{}, err
|
||||||
}
|
}
|
||||||
tenant.Role = "owner"
|
tenant.Role = string(role.Owner)
|
||||||
|
tenant.Permissions = role.Owner.Permissions()
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return Tenant{}, err
|
return Tenant{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -225,11 +238,13 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic
|
||||||
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
|
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
|
||||||
SELECT $1, $2, $3, $4, $5
|
SELECT $1, $2, $3, $4, $5
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin')
|
SELECT 1 FROM memberships
|
||||||
|
WHERE tenant_id = $1 AND user_id = $6 AND role = ANY($7)
|
||||||
)
|
)
|
||||||
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
|
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
|
||||||
client_id, to_json(redirect_uris), false`,
|
client_id, to_json(redirect_uris), false`,
|
||||||
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID)
|
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID,
|
||||||
|
role.Grants(role.ManageApplications))
|
||||||
var allowedHosts, redirectURIs []byte
|
var allowedHosts, redirectURIs []byte
|
||||||
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
|
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
|
@ -273,9 +288,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
|
||||||
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
|
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
|
||||||
FROM applications a
|
FROM applications a
|
||||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||||
WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin')
|
WHERE a.id = $1 AND m.user_id = $6 AND m.role = ANY($7)
|
||||||
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
|
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
|
||||||
applicationID, name, prefix, hash, expiresAt, userID).
|
applicationID, name, prefix, hash, expiresAt, userID, role.Grants(role.ManageKeys)).
|
||||||
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
|
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return APIKey{}, ErrNotFound
|
return APIKey{}, ErrNotFound
|
||||||
|
|
@ -287,8 +302,8 @@ func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
||||||
result, err := s.db.ExecContext(ctx, `
|
result, err := s.db.ExecContext(ctx, `
|
||||||
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
|
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
|
||||||
FROM memberships m
|
FROM memberships m
|
||||||
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`,
|
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`,
|
||||||
keyID, userID)
|
keyID, userID, role.Grants(role.ManageKeys))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -349,9 +364,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
|
||||||
SELECT a.id, $2, $3, $4, $5
|
SELECT a.id, $2, $3, $4, $5
|
||||||
FROM applications a
|
FROM applications a
|
||||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||||
WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin')
|
WHERE a.id = $1 AND m.user_id = $5 AND m.role = ANY($6)
|
||||||
RETURNING id, application_id, version, source_url, created_at`,
|
RETURNING id, application_id, version, source_url, created_at`,
|
||||||
applicationID, document.Version, sourceURL, document.JSON, userID).
|
applicationID, document.Version, sourceURL, document.JSON, userID,
|
||||||
|
role.Grants(role.ManageContracts)).
|
||||||
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
|
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Contract{}, ErrNotFound
|
return Contract{}, ErrNotFound
|
||||||
|
|
|
||||||
55
web/app.js
55
web/app.js
|
|
@ -1,5 +1,15 @@
|
||||||
const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''};
|
const state = {
|
||||||
|
tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [],
|
||||||
|
roles: [], profile: null, selectedTenant: ''
|
||||||
|
};
|
||||||
|
// Capability names mirror internal/role. The server sends the grants for the
|
||||||
|
// selected tenant so the console never re-implements the matrix.
|
||||||
|
const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission));
|
||||||
const $ = selector => document.querySelector(selector);
|
const $ = selector => document.querySelector(selector);
|
||||||
|
|
||||||
|
const roleOptions = roles => roles
|
||||||
|
.map(item => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
|
||||||
|
.join('');
|
||||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||||
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|
@ -38,14 +48,38 @@ async function load() {
|
||||||
const data = await api('/api/v1/dashboard');
|
const data = await api('/api/v1/dashboard');
|
||||||
Object.assign(state, data);
|
Object.assign(state, data);
|
||||||
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
|
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
|
||||||
$('#identity').textContent = data.user.display_name || data.user.email;
|
renderIdentity();
|
||||||
|
renderRoleOptions();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderIdentity() {
|
||||||
|
const profile = state.profile || {};
|
||||||
|
$('#identity-name').textContent = profile.display_name || profile.email || '';
|
||||||
|
const image = $('#identity-avatar');
|
||||||
|
if (profile.avatar_url) {
|
||||||
|
image.src = profile.avatar_url;
|
||||||
|
image.hidden = false
|
||||||
|
} else {
|
||||||
|
image.hidden = true;
|
||||||
|
image.removeAttribute('src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleOptions() {
|
||||||
|
// Inviting an owner is rejected by the server, so leave it out of the list.
|
||||||
|
const invitable = state.roles.filter(item => item.value !== 'owner');
|
||||||
|
$('#invite-role').innerHTML = roleOptions(invitable);
|
||||||
|
$('#access-role').innerHTML = roleOptions(state.roles);
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const tenantID = state.selectedTenant;
|
const tenantID = state.selectedTenant;
|
||||||
const selectedTenant = state.tenants.find(item => item.id === tenantID);
|
const selectedTenant = state.tenants.find(item => item.id === tenantID);
|
||||||
const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role);
|
const canManageApplications = grants(selectedTenant, 'applications.manage');
|
||||||
|
const canManageKeys = grants(selectedTenant, 'keys.manage');
|
||||||
|
const canManageContracts = grants(selectedTenant, 'contracts.manage');
|
||||||
|
const canManageUsers = grants(selectedTenant, 'users.manage');
|
||||||
const tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
|
const tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
|
||||||
const tenantAppIDs = new Set(tenantApps.map(item => item.id));
|
const tenantAppIDs = new Set(tenantApps.map(item => item.id));
|
||||||
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id));
|
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id));
|
||||||
|
|
@ -60,12 +94,15 @@ function render() {
|
||||||
$('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
|
$('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
|
||||||
$('#new-tenant').hidden = !state.is_system_administrator;
|
$('#new-tenant').hidden = !state.is_system_administrator;
|
||||||
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet');
|
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet');
|
||||||
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageTenant ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
|
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageApplications ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
|
||||||
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageTenant && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
|
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageKeys && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
|
||||||
$('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported');
|
$('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported');
|
||||||
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : '';
|
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : '';
|
||||||
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageTenant ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users');
|
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td>${avatarCell(item)}<strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageUsers ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users');
|
||||||
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant);
|
$$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
|
||||||
|
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
|
||||||
|
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
|
||||||
|
$$('[data-tenant-user-action]').forEach(element => element.hidden = !canManageUsers);
|
||||||
$$('.tenant-select').forEach(select => {
|
$$('.tenant-select').forEach(select => {
|
||||||
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
||||||
select.value = tenantID;
|
select.value = tenantID;
|
||||||
|
|
@ -74,6 +111,10 @@ function render() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty = text => `<article class="card"><p>${text}</p></article>`;
|
const empty = text => `<article class="card"><p>${text}</p></article>`;
|
||||||
|
const initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?';
|
||||||
|
const avatarCell = item => item.avatar_url
|
||||||
|
? `<img class="avatar" src="${escapeHTML(item.avatar_url)}" alt="" width="28" height="28" loading="lazy">`
|
||||||
|
: `<span class="avatar avatar-empty">${escapeHTML(initials(item.display_name || item.email))}</span>`;
|
||||||
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
|
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
|
||||||
|
|
||||||
function formJSON(form) {
|
function formJSON(form) {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
|
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
|
||||||
</label>
|
</label>
|
||||||
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
|
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
|
||||||
<span id="identity" class="identity"></span>
|
<a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
|
||||||
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
|
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -58,15 +58,15 @@
|
||||||
|
|
||||||
<div class="panels">
|
<div class="panels">
|
||||||
<section class="panel active" id="applications">
|
<section class="panel active" id="applications">
|
||||||
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-action>New application</button></div>
|
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-application-action>New application</button></div>
|
||||||
<div class="card-grid" id="app-list"></div>
|
<div class="card-grid" id="app-list"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" id="keys">
|
<section class="panel" id="keys">
|
||||||
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-action>Issue key</button></div>
|
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-key-action>Issue key</button></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" id="contracts">
|
<section class="panel" id="contracts">
|
||||||
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-action>Import contract</button></div>
|
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-contract-action>Import contract</button></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" id="tenants">
|
<section class="panel" id="tenants">
|
||||||
|
|
@ -74,8 +74,9 @@
|
||||||
<div class="card-grid" id="tenant-list"></div>
|
<div class="card-grid" id="tenant-list"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" id="users">
|
<section class="panel" id="users">
|
||||||
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-action>Invite user</button></div>
|
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-user-action>Invite user</button></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div>
|
||||||
|
<p class="muted">Roles come from the shared permission matrix. <a href="/profile">Open your own profile</a> to change your name and picture.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -86,8 +87,8 @@
|
||||||
<dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog>
|
<dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog>
|
||||||
<dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0 paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog>
|
<dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0 paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog>
|
||||||
<dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog>
|
<dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog>
|
||||||
<dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label><button type="submit">Invite user</button></form></dialog>
|
<dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role" id="invite-role" required></select></label><button type="submit">Invite user</button></form></dialog>
|
||||||
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option><option value="owner">Owner</option></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
|
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role" id="access-role" required></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
|
||||||
<dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog>
|
<dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog>
|
||||||
|
|
||||||
<script src="/assets/app.js" defer></script>
|
<script src="/assets/app.js" defer></script>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<body class="login-page">
|
<body class="login-page">
|
||||||
<div class="login-glow"></div>
|
<div class="login-glow"></div>
|
||||||
<main class="terminal">
|
<main class="terminal">
|
||||||
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@access-plane:~</strong></div>
|
<div class="terminal-bar"><span></span><span></span><span></span><strong>{{LOGIN_PROMPT}}</strong></div>
|
||||||
<div class="terminal-body">
|
<div class="terminal-body">
|
||||||
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
|
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
|
||||||
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
|
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
|
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
|
||||||
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
|
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
|
||||||
<p class="eyebrow">{{LOGIN_CONTEXT}}</p>
|
<p class="eyebrow">{{LOGIN_CONTEXT}}</p>
|
||||||
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p>
|
<p><span class="prompt">{{LOGIN_PROMPT}}$</span> initialize secure session</p>
|
||||||
<p class="muted">{{LOGIN_MESSAGE}}</p>
|
<p class="muted">{{LOGIN_MESSAGE}}</p>
|
||||||
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
|
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
|
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
</form>
|
</form>
|
||||||
<div style="{{SSO_STYLE}}">
|
<div style="{{SSO_STYLE}}">
|
||||||
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
|
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
|
||||||
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p>
|
<p class="cursor-line"><span class="prompt">{{LOGIN_PROMPT}}$</span> <span class="cursor">█</span></p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>
|
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>
|
||||||
|
|
|
||||||
103
web/openapi.json
103
web/openapi.json
|
|
@ -38,6 +38,50 @@
|
||||||
"api_key_id": {"type": "string", "format": "uuid"},
|
"api_key_id": {"type": "string", "format": "uuid"},
|
||||||
"api_key_name": {"type": "string"}
|
"api_key_name": {"type": "string"}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Membership": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A tenant role held by a user, with the capabilities it grants.",
|
||||||
|
"properties": {
|
||||||
|
"tenant_id": {"type": "string", "format": "uuid"},
|
||||||
|
"tenant_slug": {"type": "string"},
|
||||||
|
"tenant_name": {"type": "string"},
|
||||||
|
"role": {"type": "string", "enum": ["owner", "admin", "developer", "operator", "viewer", "user"]},
|
||||||
|
"permissions": {"type": "array", "items": {"type": "string"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Profile": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string", "format": "uuid"},
|
||||||
|
"email": {"type": "string", "format": "email"},
|
||||||
|
"display_name": {"type": "string"},
|
||||||
|
"avatar_url": {"type": "string", "description": "Absolute picture URL, or an empty string when no picture is set."},
|
||||||
|
"memberships": {"type": "array", "items": {"$ref": "#/components/schemas/Membership"}},
|
||||||
|
"is_system_administrator": {"type": "boolean"},
|
||||||
|
"name_customized": {"type": "boolean", "description": "True once the user set the display name themselves, which stops the identity provider from overwriting it."},
|
||||||
|
"created_at": {"type": "string", "format": "date-time"},
|
||||||
|
"last_login_at": {"type": "string", "format": "date-time"},
|
||||||
|
"profile_updated_at": {"type": "string", "format": "date-time"},
|
||||||
|
"avatar_updated_at": {"type": "string", "format": "date-time"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ProfileUpdate": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Send display_name, avatar, or remove_avatar. avatar accepts raw base64 or a data URL.",
|
||||||
|
"properties": {
|
||||||
|
"display_name": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||||
|
"avatar": {"type": "string", "description": "Base64 or data URL of a JPEG, PNG, or GIF up to 2 MiB and 4096 by 4096 pixels."},
|
||||||
|
"remove_avatar": {"type": "boolean"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RoleCatalogEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {"type": "string"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"permissions": {"type": "array", "items": {"type": "string"}}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -93,10 +137,54 @@
|
||||||
"Dashboard Management"
|
"Dashboard Management"
|
||||||
],
|
],
|
||||||
"summary": "Get management console data",
|
"summary": "Get management console data",
|
||||||
|
"description": "Requires a role that grants console.read. Each tenant and user entry carries the capability names its role grants.",
|
||||||
"security": [{"session": []}],
|
"security": [{"session": []}],
|
||||||
"responses": {"200": {"description": "Dashboard data"}}
|
"responses": {"200": {"description": "Dashboard data"}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/profile": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Profile"],
|
||||||
|
"summary": "Open the self-service profile screen",
|
||||||
|
"description": "Available to every authenticated management session, including self-service User members.",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"responses": {"200": {"description": "Profile screen"}, "303": {"description": "Redirect to login"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/profile": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Profile"],
|
||||||
|
"summary": "Read the caller's own profile and tenant roles",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
|
||||||
|
"401": {"description": "Authentication required"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": ["Profile"],
|
||||||
|
"summary": "Update the caller's display name or profile picture",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProfileUpdate"}}}},
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Updated profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}},
|
||||||
|
"400": {"description": "Invalid display name or unsupported picture"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/avatars/{key}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Profile"],
|
||||||
|
"summary": "Fetch a profile picture",
|
||||||
|
"description": "Public on purpose so other applications can embed the picture with a plain image tag. The key is 256 bits of randomness and exposes nothing about the user.",
|
||||||
|
"parameters": [{"in": "path", "name": "key", "required": true, "schema": {"type": "string"}}],
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Image bytes", "content": {"image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/png": {"schema": {"type": "string", "format": "binary"}}, "image/gif": {"schema": {"type": "string", "format": "binary"}}}},
|
||||||
|
"304": {"description": "Not modified"},
|
||||||
|
"404": {"description": "No picture for that key"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/tenants": {
|
"/api/v1/tenants": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
@ -169,6 +257,7 @@
|
||||||
"User Action"
|
"User Action"
|
||||||
],
|
],
|
||||||
"summary": "Exchange a one-time authorization code",
|
"summary": "Exchange a one-time authorization code",
|
||||||
|
"description": "The nested user object carries role, permissions, and avatar_url for the tenant owning the application.",
|
||||||
"security": [{"clientBasic": []}],
|
"security": [{"clientBasic": []}],
|
||||||
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}},
|
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}},
|
||||||
"responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}}
|
"responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}}
|
||||||
|
|
@ -179,7 +268,8 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"User Action"
|
"User Action"
|
||||||
],
|
],
|
||||||
"summary": "Validate a client user session",
|
"summary": "Validate a client user session and read the caller's role and picture",
|
||||||
|
"description": "Returns id, email, display_name, avatar_url, tenant and application identity, role, and permissions.",
|
||||||
"security": [{"userBearer": []}],
|
"security": [{"userBearer": []}],
|
||||||
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
|
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +300,7 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"User Management"
|
"User Management"
|
||||||
],
|
],
|
||||||
"summary": "Invite a tenant user",
|
"summary": "Invite a tenant user with an admin, developer, operator, viewer, or user role",
|
||||||
"security": [{"session": []}],
|
"security": [{"session": []}],
|
||||||
"responses": {"201": {"description": "User invited"}}
|
"responses": {"201": {"description": "User invited"}}
|
||||||
}
|
}
|
||||||
|
|
@ -221,11 +311,20 @@
|
||||||
"User Management"
|
"User Management"
|
||||||
],
|
],
|
||||||
"summary": "Set tenant role and application access",
|
"summary": "Set tenant role and application access",
|
||||||
|
"description": "Any known role is accepted, including owner.",
|
||||||
"security": [{"session": []}],
|
"security": [{"session": []}],
|
||||||
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||||
"responses": {"204": {"description": "Access updated"}}
|
"responses": {"204": {"description": "Access updated"}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/roles": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["User Management"],
|
||||||
|
"summary": "List tenant roles with their capabilities",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/RoleCatalogEntry"}}}}}}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/users/{id}/approve": {
|
"/api/v1/users/{id}/approve": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": ["User Management"],
|
"tags": ["User Management"],
|
||||||
|
|
|
||||||
79
web/profile.html
Normal file
79
web/profile.html
Normal 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
166
web/profile.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||||
|
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[c]));
|
||||||
|
const date = value => value ? new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(value)) : 'Never';
|
||||||
|
const notice = (message, error = false) => {
|
||||||
|
const el = $('#notice');
|
||||||
|
el.textContent = message;
|
||||||
|
el.hidden = false;
|
||||||
|
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
|
||||||
|
clearTimeout(notice.timer);
|
||||||
|
notice.timer = setTimeout(() => el.hidden = true, 5000)
|
||||||
|
};
|
||||||
|
const avatar = {dataURL: '', fileName: '', pending: false};
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: 'same-origin', ...options,
|
||||||
|
headers: {'Content-Type': 'application/json', ...(options.headers || {})}
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
location.href = '/login';
|
||||||
|
throw new Error('Session expired')
|
||||||
|
}
|
||||||
|
const body = response.status === 204 ? null : await response.json();
|
||||||
|
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
render(await api('/api/v1/profile'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(profile) {
|
||||||
|
$('#display-name').value = profile.display_name;
|
||||||
|
$('#email').value = profile.email;
|
||||||
|
$('#system-admin').textContent = profile.is_system_administrator ? 'Yes' : 'No';
|
||||||
|
$('#created-at').textContent = date(profile.created_at);
|
||||||
|
$('#last-login').textContent = date(profile.last_login_at);
|
||||||
|
$('#name-source').textContent = profile.name_customized ? 'You' : 'Your identity provider';
|
||||||
|
renderAvatar(profile);
|
||||||
|
$('#membership-list').innerHTML = profile.memberships.map(item => `<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));
|
||||||
|
|
@ -16,3 +16,4 @@ dialog{color:var(--text);background:var(--surface);border:1px solid var(--border
|
||||||
.password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}
|
.password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}
|
||||||
.installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)}
|
.installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)}
|
||||||
@media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}}
|
@media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}}
|
||||||
|
.identity{display:inline-flex;align-items:center;gap:8px;text-decoration:none}.identity img,.avatar{border-radius:50%;object-fit:cover;display:block;flex:none}.profile-hero{min-height:0;padding:56px 0 34px}.profile-hero h1{font-size:clamp(40px,5vw,64px)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin-top:34px}.profile-card{display:grid;gap:16px;align-content:start}.profile-card h2{margin:0;font-size:20px;letter-spacing:-.02em}.profile-wide{grid-column:1/-1}.profile-card form{display:grid;gap:14px}.profile-card label{display:grid;gap:7px;font-size:13px;color:var(--muted)}.profile-actions{display:flex;gap:10px;flex-wrap:wrap}.profile-actions button{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions .ghost{background:transparent;border:1px solid var(--border);color:var(--text)}.profile-card button[type=submit]{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer;justify-self:start}.avatar-preview{display:grid;place-items:center;width:112px;height:112px;border-radius:50%;overflow:hidden;background:var(--surface2);border:1px solid var(--border)}.avatar-preview img{width:100%;height:100%;object-fit:cover}.avatar-initials{font:750 34px ui-monospace,monospace;color:var(--accent)}.avatar-empty{display:inline-grid;place-items:center;width:28px;height:28px;vertical-align:middle;background:var(--surface2);border:1px solid var(--border);color:var(--accent);font:700 11px ui-monospace,monospace}.facts{display:grid;gap:10px;margin:4px 0 0}.facts div{display:flex;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);padding-bottom:8px;font-size:13px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right}.capabilities{display:flex;flex-wrap:wrap;gap:6px}.chip{font:600 11px ui-monospace,monospace;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 6px}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue