roles
This commit is contained in:
parent
6319da650c
commit
3fdc8cb489
26 changed files with 2245 additions and 488 deletions
|
|
@ -3,4 +3,5 @@
|
||||||
bin
|
bin
|
||||||
coverage.out
|
coverage.out
|
||||||
google_secrets.json
|
google_secrets.json
|
||||||
|
data
|
||||||
|
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -1,6 +1,7 @@
|
||||||
SHELL := /bin/sh
|
SHELL := /bin/sh
|
||||||
|
|
||||||
GO_LOOSE_ADDR ?= :8081
|
GO_LOOSE_ADDR ?= :8081
|
||||||
|
GO_LOOSE_PORT ?= 8081
|
||||||
GO_LOOSE_BASE_URL ?= http://localhost:$(GO_LOOSE_PORT)
|
GO_LOOSE_BASE_URL ?= http://localhost:$(GO_LOOSE_PORT)
|
||||||
GO_LOOSE_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose?sslmode=disable
|
GO_LOOSE_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose?sslmode=disable
|
||||||
GO_LOOSE_SESSION_SECRET ?= local-development-secret-change-me-now
|
GO_LOOSE_SESSION_SECRET ?= local-development-secret-change-me-now
|
||||||
|
|
@ -29,8 +30,12 @@ integration-test:
|
||||||
run:
|
run:
|
||||||
go run ./cmd/go-loose
|
go run ./cmd/go-loose
|
||||||
|
|
||||||
|
# The variables above are exported for `make run`, but compose reads
|
||||||
|
# GO_LOOSE_BASE_URL too and would inherit the local http://localhost:8081 value,
|
||||||
|
# which then overrides the https:// defaults in docker-compose.yaml. Clearing it
|
||||||
|
# for the compose targets lets compose apply its own defaults.
|
||||||
compose-up:
|
compose-up:
|
||||||
docker compose up --build
|
GO_LOOSE_BASE_URL= docker compose up --build
|
||||||
|
|
||||||
compose-down:
|
compose-down:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,71 @@
|
||||||
# Roles and capabilities
|
# 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.
|
A role is a named bundle of permissions. Roles are rows: six built-in ones seeded by migration `0006_database_roles.sql`, plus whatever each tenant defines for itself. `internal/role` no longer holds a matrix — it holds the closed vocabulary of permissions a role may be granted, and the database resolves what a membership actually grants.
|
||||||
|
|
||||||
## Capability matrix
|
```
|
||||||
|
roles one row per role. tenant_id NULL means built-in.
|
||||||
|
role_permissions (role_id, permission) — what the role grants.
|
||||||
|
memberships.role_id → roles.id, NOT NULL, ON DELETE RESTRICT
|
||||||
|
```
|
||||||
|
|
||||||
| Capability | owner | admin | developer | operator | viewer | user |
|
## Built-in capability matrix
|
||||||
|
|
||||||
|
This is what the migration seeds. It is data, not code: change it with a new migration, not by editing Go.
|
||||||
|
|
||||||
|
| Permission | owner | admin | developer | operator | viewer | user |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `console.read` | yes | yes | yes | yes | yes | no |
|
| `tenants.manage` | yes | no | no | no | no | no |
|
||||||
|
| `roles.manage` | yes | yes | no | no | no | no |
|
||||||
|
| `users.manage` | yes | yes | yes | yes | no | no |
|
||||||
| `applications.manage` | yes | yes | yes | no | no | no |
|
| `applications.manage` | yes | yes | yes | no | no | no |
|
||||||
| `keys.manage` | yes | yes | yes | yes | no | no |
|
| `keys.manage` | yes | yes | yes | yes | no | no |
|
||||||
| `contracts.manage` | yes | yes | yes | yes | no | no |
|
| `contracts.manage` | yes | yes | yes | yes | no | no |
|
||||||
| `users.manage` | yes | yes | no | no | no | no |
|
| `users.read` | yes | yes | yes | yes | yes | no |
|
||||||
|
| `console.read` | yes | yes | yes | yes | yes | no |
|
||||||
| `profile.edit` | yes | yes | yes | yes | yes | yes |
|
| `profile.edit` | yes | yes | yes | yes | yes | yes |
|
||||||
|
|
||||||
Intended use:
|
Intended use:
|
||||||
|
|
||||||
- **owner** — full control of the tenant, including role assignment.
|
- **owner** — full control of the tenant, including defining roles and handing them out.
|
||||||
- **admin** — manages applications, keys, contracts, and members.
|
- **admin** — manages applications, keys, contracts, members, and roles.
|
||||||
- **developer** — ships service contracts and API keys, but cannot change membership.
|
- **developer** — ships applications, keys, and contracts, and maintains the tenant's members.
|
||||||
- **operator** — runs existing applications: rotates keys and imports contracts.
|
- **operator** — runs existing applications and maintains the tenant's members.
|
||||||
- **viewer** — read-only console access.
|
- **viewer** — read-only console access.
|
||||||
- **user** — self-service only. No console access; the session lands on `/profile`.
|
- **user** — self-service only. No console access; the session lands on `/profile`.
|
||||||
|
|
||||||
|
`developer` and `operator` hold `users.manage` so that anyone who runs the console can maintain their own tenant's members. `roles.manage` deliberately stays narrow: defining roles is what defines privilege, so it is not something a member administrator can grant themselves.
|
||||||
|
|
||||||
`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`.
|
`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`.
|
||||||
|
|
||||||
|
## Tenant-defined roles
|
||||||
|
|
||||||
|
`POST /api/v1/roles` creates a role for one tenant. It is visible only in that tenant, and the `/roles` screen lists it beside the built-ins with its permission chips.
|
||||||
|
|
||||||
|
Three rules keep this from becoming a privilege-escalation hole:
|
||||||
|
|
||||||
|
1. **Roles are scoped.** `roles.tenant_id` is set, and every query that resolves a role filters on the caller's own tenant, so a role id cannot be probed across tenants.
|
||||||
|
2. **You cannot mint what you do not hold.** Creating or editing a role requires `roles.manage` *and* requires every permission in the role to be one the caller already has. Assigning a role enforces the same subset rule.
|
||||||
|
3. **A role in use cannot be deleted.** `memberships.role_id` is `ON DELETE RESTRICT`, and `DELETE /api/v1/roles/{id}` reports 409 until the members are moved. Built-in roles are immutable and report 404.
|
||||||
|
|
||||||
|
Built-in roles are read only: the store refuses to edit or delete a row with `is_system` set.
|
||||||
|
|
||||||
## Where the checks live
|
## 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`.
|
Tenant-scoped statements resolve the grant through `role_permissions` using one shared predicate, `permissionPredicate` in `internal/store/roles.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.
|
```sql
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM memberships scoped
|
||||||
|
JOIN role_permissions granted ON granted.role_id = scoped.role_id
|
||||||
|
WHERE scoped.tenant_id = $1 AND scoped.user_id = $2
|
||||||
|
AND granted.permission = $3
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
Because the check asks for a permission rather than a role name, a tenant's own role works everywhere a built-in one does without any Go change. The server never decides authorization: `handleStoreError` maps `store.ErrNotFound` to a 404 so a denied write and a missing row are indistinguishable.
|
||||||
|
|
||||||
|
`role.Permission` names are also constrained by a check constraint on `role_permissions.permission`, so an unknown capability cannot be stored even if a caller bypasses the Go layer. **Adding a permission therefore needs three edits**: the constant in `internal/role/role.go`, the check constraint and seed in a new migration, and the expected grants in `internal/store/roles_integration_test.go`.
|
||||||
|
|
||||||
## Display names and profile pictures
|
## Display names and profile pictures
|
||||||
|
|
||||||
|
|
@ -50,4 +85,4 @@ Accepted formats are JPEG, PNG, and GIF up to 2 MiB and 4096 by 4096 pixels. Rep
|
||||||
|
|
||||||
`/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.
|
`/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`.
|
A user with no membership in the application's tenant still authenticates, but the role falls back to the built-in `user` role with only `profile.edit`.
|
||||||
106
internal/database/migrations/0006_database_roles.sql
Normal file
106
internal/database/migrations/0006_database_roles.sql
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Roles become rows so a tenant can define its own. A NULL tenant_id marks a
|
||||||
|
-- built-in role: every tenant sees it and nobody may edit or delete it.
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 100),
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
is_system boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
-- Custom roles belong to exactly one tenant; built-in roles to none.
|
||||||
|
CONSTRAINT roles_scope CHECK (
|
||||||
|
(is_system AND tenant_id IS NULL) OR (NOT is_system AND tenant_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX roles_system_slug_unique ON roles (slug) WHERE tenant_id IS NULL;
|
||||||
|
CREATE UNIQUE INDEX roles_tenant_slug_unique ON roles (tenant_id, slug) WHERE tenant_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The permission vocabulary is closed, so keep this list in step with the
|
||||||
|
-- constants in internal/role.
|
||||||
|
CREATE TABLE role_permissions (
|
||||||
|
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
permission text NOT NULL CHECK (permission IN (
|
||||||
|
'console.read', 'users.read', 'applications.manage', 'keys.manage',
|
||||||
|
'contracts.manage', 'users.manage', 'roles.manage', 'tenants.manage',
|
||||||
|
'profile.edit')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (role_id, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO roles (slug, name, description, is_system) VALUES
|
||||||
|
('owner', 'Owner', 'Full control of the tenant, including roles, members, and tenant settings.', true),
|
||||||
|
('admin', 'Administrator', 'Manages applications, credentials, contracts, members, and roles.', true),
|
||||||
|
('developer', 'Developer', 'Manages applications, credentials, contracts, and members.', true),
|
||||||
|
('operator', 'Operator', 'Issues and revokes credentials, imports contracts, and manages members.', true),
|
||||||
|
('viewer', 'Viewer', 'Reads the console without changing anything.', true),
|
||||||
|
('user', 'User', 'Self-service member with no console access.', true);
|
||||||
|
|
||||||
|
-- Seed what used to be the Go matrix. developer and operator join owner and
|
||||||
|
-- admin in holding users.manage so that any tenant member who runs the console
|
||||||
|
-- can maintain their own tenant's members. roles.manage stays with owner and
|
||||||
|
-- admin: minting roles is what defines privilege in the first place.
|
||||||
|
INSERT INTO role_permissions (role_id, permission)
|
||||||
|
SELECT r.id, granted.permission
|
||||||
|
FROM (VALUES
|
||||||
|
('owner', ARRAY[
|
||||||
|
'console.read', 'users.read', 'applications.manage', 'keys.manage',
|
||||||
|
'contracts.manage', 'users.manage', 'roles.manage', 'tenants.manage',
|
||||||
|
'profile.edit']),
|
||||||
|
('admin', ARRAY[
|
||||||
|
'console.read', 'users.read', 'applications.manage', 'keys.manage',
|
||||||
|
'contracts.manage', 'users.manage', 'roles.manage', 'profile.edit']),
|
||||||
|
('developer', ARRAY[
|
||||||
|
'console.read', 'users.read', 'applications.manage', 'keys.manage',
|
||||||
|
'contracts.manage', 'users.manage', 'profile.edit']),
|
||||||
|
('operator', ARRAY[
|
||||||
|
'console.read', 'users.read', 'keys.manage', 'contracts.manage',
|
||||||
|
'users.manage', 'profile.edit']),
|
||||||
|
('viewer', ARRAY['console.read', 'users.read', 'profile.edit']),
|
||||||
|
('user', ARRAY['profile.edit'])
|
||||||
|
) AS seed(slug, permissions)
|
||||||
|
JOIN roles r ON r.slug = seed.slug AND r.tenant_id IS NULL
|
||||||
|
CROSS JOIN LATERAL unnest(seed.permissions) AS granted(permission);
|
||||||
|
|
||||||
|
-- Point every membership at a role row. RESTRICT rather than CASCADE so that a
|
||||||
|
-- role still held by somebody cannot be deleted out from under them.
|
||||||
|
ALTER TABLE memberships ADD COLUMN role_id uuid REFERENCES roles(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
UPDATE memberships m SET role_id = r.id
|
||||||
|
FROM roles r
|
||||||
|
WHERE r.tenant_id IS NULL AND r.slug = m.role;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM memberships WHERE role_id IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'cannot migrate memberships: a membership names a role that was never seeded';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
ALTER TABLE memberships ALTER COLUMN role_id SET NOT NULL;
|
||||||
|
ALTER TABLE memberships DROP CONSTRAINT memberships_role_check;
|
||||||
|
ALTER TABLE memberships DROP COLUMN role;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE memberships ADD COLUMN role text;
|
||||||
|
|
||||||
|
UPDATE memberships m SET role = r.slug
|
||||||
|
FROM roles r
|
||||||
|
WHERE r.id = m.role_id AND r.is_system;
|
||||||
|
|
||||||
|
-- Custom roles have no equivalent in the old fixed vocabulary.
|
||||||
|
UPDATE memberships SET role = 'viewer' WHERE role IS NULL;
|
||||||
|
|
||||||
|
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 memberships ALTER COLUMN role SET NOT NULL;
|
||||||
|
ALTER TABLE memberships DROP COLUMN role_id;
|
||||||
|
|
||||||
|
DROP TABLE role_permissions;
|
||||||
|
DROP TABLE roles;
|
||||||
|
|
@ -1,175 +1,132 @@
|
||||||
// Package role defines the tenant membership roles and the single permission
|
// Package role defines the permission vocabulary that authorizes console and
|
||||||
// matrix that authorizes console and management behavior.
|
// management behavior.
|
||||||
//
|
//
|
||||||
// Roles are stored in memberships.role. Authorization decisions must derive
|
// Roles are rows in the roles table and their permissions live in
|
||||||
// from Grants or Allows so that SQL role checks and Go checks cannot drift
|
// role_permissions, so a tenant can define its own and authorization is
|
||||||
// apart.
|
// resolved by the database rather than from a matrix held here. What belongs in
|
||||||
|
// this package is the closed set of permissions a role may be granted, plus the
|
||||||
|
// labels the console renders.
|
||||||
|
//
|
||||||
|
// Keep the permission list in step with the role_permissions_permission_check
|
||||||
|
// constraint in migration 0006: a name that is not in both places cannot be
|
||||||
|
// stored.
|
||||||
package role
|
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.
|
// Permission is a capability granted by a role.
|
||||||
type Permission string
|
type Permission string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// ReadConsole opens the management console. Self-service members lack it so
|
||||||
|
// that they are routed to their profile instead.
|
||||||
ReadConsole Permission = "console.read"
|
ReadConsole Permission = "console.read"
|
||||||
|
// ReadUsers lists a tenant's members and their roles.
|
||||||
ReadUsers Permission = "users.read"
|
ReadUsers Permission = "users.read"
|
||||||
|
// ManageApplications registers applications and configures their client
|
||||||
|
// credentials.
|
||||||
ManageApplications Permission = "applications.manage"
|
ManageApplications Permission = "applications.manage"
|
||||||
|
// ManageKeys issues and revokes API keys.
|
||||||
ManageKeys Permission = "keys.manage"
|
ManageKeys Permission = "keys.manage"
|
||||||
|
// ManageContracts imports OpenAPI contracts.
|
||||||
ManageContracts Permission = "contracts.manage"
|
ManageContracts Permission = "contracts.manage"
|
||||||
|
// ManageUsers invites members and changes the roles and application grants
|
||||||
|
// of existing ones.
|
||||||
ManageUsers Permission = "users.manage"
|
ManageUsers Permission = "users.manage"
|
||||||
|
// ManageRoles creates and edits the tenant's own roles. Kept separate from
|
||||||
|
// ManageUsers because defining roles is what defines privilege.
|
||||||
|
ManageRoles Permission = "roles.manage"
|
||||||
|
// ManageTenants creates tenants and administers the installation.
|
||||||
ManageTenants Permission = "tenants.manage"
|
ManageTenants Permission = "tenants.manage"
|
||||||
|
// EditProfile changes one's own display name and picture.
|
||||||
EditProfile Permission = "profile.edit"
|
EditProfile Permission = "profile.edit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// catalog lists every role in descending order of privilege.
|
// ordered lists every permission from most to least sensitive. The order is the
|
||||||
var catalog = []Role{Owner, Admin, Developer, Operator, Viewer, User}
|
// one the console renders checkboxes in.
|
||||||
|
|
||||||
// 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{
|
var ordered = []Permission{
|
||||||
ReadConsole, ReadUsers, ManageApplications, ManageKeys,
|
ManageTenants,
|
||||||
ManageContracts, ManageUsers, ManageTenants, EditProfile,
|
ManageRoles,
|
||||||
|
ManageUsers,
|
||||||
|
ManageApplications,
|
||||||
|
ManageKeys,
|
||||||
|
ManageContracts,
|
||||||
|
ReadUsers,
|
||||||
|
ReadConsole,
|
||||||
|
EditProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry describes a role for the console and for API clients.
|
var known = map[Permission]bool{
|
||||||
|
ReadConsole: true,
|
||||||
|
ReadUsers: true,
|
||||||
|
ManageApplications: true,
|
||||||
|
ManageKeys: true,
|
||||||
|
ManageContracts: true,
|
||||||
|
ManageUsers: true,
|
||||||
|
ManageRoles: true,
|
||||||
|
ManageTenants: true,
|
||||||
|
EditProfile: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry describes a permission for the console and for API clients.
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Permissions []string `json:"permissions"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var labels = map[Role]string{
|
var details = map[Permission]struct{ label, description string }{
|
||||||
Owner: "Owner",
|
ReadConsole: {"Read console", "Open the management console and pick tenants."},
|
||||||
Admin: "Administrator",
|
ReadUsers: {"Read members", "List the members of a tenant and the roles they hold."},
|
||||||
Developer: "Developer",
|
ManageApplications: {"Manage applications", "Register applications and configure client credentials."},
|
||||||
Operator: "Operator",
|
ManageKeys: {"Manage API keys", "Issue and revoke API keys."},
|
||||||
Viewer: "Viewer",
|
ManageContracts: {"Manage contracts", "Import OpenAPI contracts and their endpoints."},
|
||||||
User: "User",
|
ManageUsers: {"Manage members", "Invite members and change their roles and application grants."},
|
||||||
|
ManageRoles: {"Manage roles", "Create and edit the roles of this tenant."},
|
||||||
|
ManageTenants: {"Manage tenants", "Create tenants and administer the installation."},
|
||||||
|
EditProfile: {"Edit own profile", "Change one's own display name and picture."},
|
||||||
}
|
}
|
||||||
|
|
||||||
// All returns every role ordered from most to least privileged.
|
// Permissions returns every permission in display order.
|
||||||
func All() []Role {
|
func Permissions() []Permission {
|
||||||
result := make([]Role, len(catalog))
|
result := make([]Permission, len(ordered))
|
||||||
copy(result, catalog)
|
copy(result, ordered)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catalog returns every role with its label and permissions so that the console
|
// Parse validates a submitted permission name. Unknown values are rejected so
|
||||||
// and API clients never hardcode the role list.
|
// that a crafted payload cannot invent capabilities.
|
||||||
func Catalog() []Entry {
|
func Parse(value string) (Permission, bool) {
|
||||||
result := make([]Entry, 0, len(catalog))
|
candidate := Permission(value)
|
||||||
for _, item := range catalog {
|
if !known[candidate] {
|
||||||
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 "", false
|
||||||
}
|
}
|
||||||
return candidate, true
|
return candidate, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid reports whether the value names a role.
|
// Valid reports whether the value names a permission.
|
||||||
func (r Role) Valid() bool {
|
func (p Permission) Valid() bool {
|
||||||
_, known := grants[r]
|
return known[p]
|
||||||
return known
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allows reports whether the role grants the permission.
|
// Catalog returns every permission with a label and description so that the
|
||||||
func (r Role) Allows(permission Permission) bool {
|
// console and API clients never hardcode the list.
|
||||||
return grants[r][permission]
|
func Catalog() []Entry {
|
||||||
|
result := make([]Entry, 0, len(ordered))
|
||||||
|
for _, permission := range ordered {
|
||||||
|
detail := details[permission]
|
||||||
|
result = append(result, Entry{
|
||||||
|
Value: string(permission),
|
||||||
|
Label: detail.label,
|
||||||
|
Description: detail.description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions returns the role's permissions in a stable order.
|
// Values returns every permission as strings, for use as a SQL array parameter.
|
||||||
func (r Role) Permissions() []string {
|
func Values() []string {
|
||||||
result := make([]string, 0, len(ordered))
|
result := make([]string, 0, len(ordered))
|
||||||
for _, permission := range ordered {
|
for _, permission := range ordered {
|
||||||
if r.Allows(permission) {
|
|
||||||
result = append(result, string(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
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,130 +6,112 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseRejectsUnknownRoles(t *testing.T) {
|
func TestParseRejectsUnknownPermissions(t *testing.T) {
|
||||||
tests := []struct {
|
valid := []string{
|
||||||
value string
|
"console.read", "users.read", "applications.manage", "keys.manage",
|
||||||
want bool
|
"contracts.manage", "users.manage", "roles.manage", "tenants.manage",
|
||||||
}{
|
"profile.edit",
|
||||||
{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 {
|
for _, value := range valid {
|
||||||
t.Run(test.value, func(t *testing.T) {
|
if _, ok := Parse(value); !ok {
|
||||||
_, ok := Parse(test.value)
|
t.Errorf("Parse(%q) = not ok, want a permission", value)
|
||||||
if ok != test.want {
|
|
||||||
t.Fatalf("Parse(%q) ok = %v, want %v", test.value, ok, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, value := range []string{"", "Console.read", "console", "superuser", "users.manage; DROP TABLE roles"} {
|
||||||
func TestPermissionMatrix(t *testing.T) {
|
if parsed, ok := Parse(value); ok {
|
||||||
tests := []struct {
|
t.Errorf("Parse(%q) = %q, want rejection", value, parsed)
|
||||||
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) {
|
func TestValidMatchesParse(t *testing.T) {
|
||||||
for _, permission := range ordered {
|
for _, permission := range Permissions() {
|
||||||
granted := Grants(permission)
|
if !permission.Valid() {
|
||||||
for _, item := range All() {
|
t.Errorf("%q reports itself invalid", permission)
|
||||||
if slices.Contains(granted, string(item)) != item.Allows(permission) {
|
|
||||||
t.Fatalf("Grants(%q) and Allows disagree for role %q", permission, item)
|
|
||||||
}
|
}
|
||||||
|
if parsed, ok := Parse(string(permission)); !ok || parsed != permission {
|
||||||
|
t.Errorf("Parse(%q) did not round-trip", permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Permission("nope").Valid() {
|
||||||
|
t.Error("an unknown permission reports itself valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionsAreDottedNames(t *testing.T) {
|
||||||
|
for _, permission := range Permissions() {
|
||||||
|
name := string(permission)
|
||||||
|
if len(name) < 3 || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
|
||||||
|
t.Fatalf("%q is not a plausible permission name", name)
|
||||||
|
}
|
||||||
|
dot := false
|
||||||
|
for _, character := range name {
|
||||||
|
switch {
|
||||||
|
case character == '.':
|
||||||
|
dot = true
|
||||||
|
case character >= 'a' && character <= 'z', character >= '0' && character <= '9', character == '-':
|
||||||
|
default:
|
||||||
|
t.Fatalf("%q contains %q; permissions use lowercase words", name, character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dot {
|
||||||
|
t.Errorf("%q should be a namespaced name", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGrantsListsRolesInPrivilegeOrder(t *testing.T) {
|
func TestCatalogDescribesEveryPermission(t *testing.T) {
|
||||||
if got := Grants(ManageKeys); !slices.Equal(got, []string{"owner", "admin", "developer", "operator"}) {
|
catalog := Catalog()
|
||||||
t.Fatalf("Grants(ManageKeys) = %v", got)
|
if len(catalog) != len(Permissions()) {
|
||||||
|
t.Fatalf("catalog has %d entries, want %d", len(catalog), len(Permissions()))
|
||||||
}
|
}
|
||||||
if got := Grants(ManageUsers); !slices.Equal(got, []string{"owner", "admin"}) {
|
seen := make(map[string]bool, len(catalog))
|
||||||
t.Fatalf("Grants(ManageUsers) = %v", got)
|
for _, entry := range catalog {
|
||||||
|
if entry.Value == "" || entry.Label == "" || entry.Description == "" {
|
||||||
|
t.Errorf("incomplete catalog entry %+v", entry)
|
||||||
}
|
}
|
||||||
if got := Grants(ReadConsole); !slices.Contains(got, string(Owner)) || slices.Contains(got, string(User)) {
|
if seen[entry.Value] {
|
||||||
t.Fatalf("Grants(ReadConsole) = %v", got)
|
t.Errorf("permission %q appears twice in the catalog", entry.Value)
|
||||||
|
}
|
||||||
|
seen[entry.Value] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCatalogCoversEveryRoleWithALabel(t *testing.T) {
|
// TestCatalogFollowsDisplayOrder guards the order the console renders the
|
||||||
entries := Catalog()
|
// permission checkboxes in: the most sensitive capability first.
|
||||||
if len(entries) != len(catalog) {
|
func TestCatalogFollowsDisplayOrder(t *testing.T) {
|
||||||
t.Fatalf("Catalog() length = %d, want %d", len(entries), len(catalog))
|
catalog := Catalog()
|
||||||
}
|
want := []string{"tenants.manage", "roles.manage", "users.manage"}
|
||||||
for _, entry := range entries {
|
for index, value := range want {
|
||||||
if entry.Label == "" {
|
if catalog[index].Value != value {
|
||||||
t.Fatalf("role %q has no label", entry.Value)
|
t.Errorf("catalog[%d] = %q, want %q", index, catalog[index].Value, 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) {
|
func TestPermissionsReturnsACopy(t *testing.T) {
|
||||||
for _, item := range Grantable() {
|
first := Permissions()
|
||||||
if item == Owner {
|
if len(first) == 0 {
|
||||||
t.Fatal("owner must not be grantable through an invitation")
|
t.Fatal("no permissions")
|
||||||
}
|
}
|
||||||
}
|
first[0] = "mutated"
|
||||||
if len(GrantableValues()) != len(catalog)-1 {
|
if Permissions()[0] == "mutated" {
|
||||||
t.Fatalf("GrantableValues() length = %d", len(GrantableValues()))
|
t.Error("Permissions exposed its backing array")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPermissionsUseDottedNames(t *testing.T) {
|
func TestValuesMatchesPermissions(t *testing.T) {
|
||||||
for _, permission := range All()[0].Permissions() {
|
values := Values()
|
||||||
if !strings.Contains(permission, ".") {
|
permissions := Permissions()
|
||||||
t.Fatalf("permission %q should be a dotted capability name", permission)
|
if len(values) != len(permissions) {
|
||||||
|
t.Fatalf("Values has %d entries, want %d", len(values), len(permissions))
|
||||||
|
}
|
||||||
|
for index, permission := range permissions {
|
||||||
|
if values[index] != string(permission) {
|
||||||
|
t.Errorf("Values()[%d] = %q, want %q", index, values[index], permission)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !slices.Contains(values, string(ManageUsers)) {
|
||||||
|
t.Error("users.manage is missing from Values")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,7 +32,7 @@ func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
|
||||||
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
|
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
|
||||||
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
|
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
|
||||||
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
|
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
|
||||||
Role: user.Role, Permissions: role.Role(user.Role).Permissions(),
|
Role: user.Role, Permissions: user.Permissions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,100 @@ func TestRequireSameOriginRejectsDifferentTenantHost(t *testing.T) {
|
||||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRequireSameOriginSurvivesStaleBaseURL covers the deployment that made
|
||||||
|
// profile saves fail with "same-origin request required": the container was
|
||||||
|
// started with a base URL of "http://localhost:" while users browsed to
|
||||||
|
// https://auth.urpi.be. The configured base URL must not be the only accepted
|
||||||
|
// origin, or a stale value breaks every state-changing route at once.
|
||||||
|
func TestRequireSameOriginSurvivesStaleBaseURL(t *testing.T) {
|
||||||
|
for _, baseURL := range []string{"http://localhost:", "", "http://localhost:8080", "not a url"} {
|
||||||
|
server := &Server{cfg: config.Config{BaseURL: baseURL}}
|
||||||
|
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "https://auth.urpi.be/api/v1/profile", nil)
|
||||||
|
request.Header.Set("Origin", "https://auth.urpi.be")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("base URL %q: status = %d, want %d", baseURL, response.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireSameOriginAcceptsForwardedHTTPS covers a reverse proxy that
|
||||||
|
// terminates TLS: the server sees a plain HTTP connection, so the scheme has to
|
||||||
|
// come from X-Forwarded-Proto rather than from r.TLS.
|
||||||
|
func TestRequireSameOriginAcceptsForwardedHTTPS(t *testing.T) {
|
||||||
|
server := &Server{cfg: config.Config{BaseURL: "http://go-loose:8080"}}
|
||||||
|
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "http://auth.urpi.be/api/v1/profile", nil)
|
||||||
|
request.Host = "auth.urpi.be"
|
||||||
|
request.Header.Set("Origin", "https://auth.urpi.be")
|
||||||
|
request.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireSameOriginFallsBackToReferer(t *testing.T) {
|
||||||
|
server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}}
|
||||||
|
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil)
|
||||||
|
request.Header.Set("Referer", "https://auth.dev/console")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireSameOriginRejectsMissingAndForeignSources(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
origin string
|
||||||
|
referer string
|
||||||
|
}{
|
||||||
|
{name: "no origin or referer"},
|
||||||
|
{name: "foreign origin", origin: "https://evil.example"},
|
||||||
|
{name: "null origin", origin: "null"},
|
||||||
|
{name: "same host different scheme", origin: "http://auth.dev"},
|
||||||
|
{name: "origin is a prefix of the host", origin: "https://auth.dev.evil.example"},
|
||||||
|
{name: "referer from another tenant", referer: "https://ypto.auth.dev/profile"},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}}
|
||||||
|
handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("handler reached for a request that was not same-origin")
|
||||||
|
}))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "https://auth.dev/api/v1/tenants", nil)
|
||||||
|
if testCase.origin != "" {
|
||||||
|
request.Header.Set("Origin", testCase.origin)
|
||||||
|
}
|
||||||
|
if testCase.referer != "" {
|
||||||
|
request.Header.Set("Referer", testCase.referer)
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,17 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClientUserExposesRoleCapabilitiesAndPicture checks that the identity
|
||||||
|
// handed to a client application carries the capabilities the store resolved for
|
||||||
|
// the membership's role rather than anything the server infers.
|
||||||
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
|
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
|
||||||
server := profileTestServer()
|
server := profileTestServer()
|
||||||
key := "avatar-key"
|
key := "avatar-key"
|
||||||
payload := server.clientUser(store.ClientUser{
|
payload := server.clientUser(store.ClientUser{
|
||||||
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
|
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
|
||||||
TenantSlug: "nmbs", Application: "guess", Role: string(role.Operator), AvatarKey: &key,
|
TenantSlug: "nmbs", Application: "guess", Role: "operator",
|
||||||
|
Permissions: []string{string(role.ManageKeys), string(role.EditProfile)},
|
||||||
|
AvatarKey: &key,
|
||||||
})
|
})
|
||||||
if payload.Role != "operator" {
|
if payload.Role != "operator" {
|
||||||
t.Fatalf("role = %q", payload.Role)
|
t.Fatalf("role = %q", payload.Role)
|
||||||
|
|
@ -65,17 +70,21 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
|
||||||
if !slices.Contains(payload.Permissions, string(role.ManageKeys)) {
|
if !slices.Contains(payload.Permissions, string(role.ManageKeys)) {
|
||||||
t.Fatalf("permissions = %v, want keys.manage", payload.Permissions)
|
t.Fatalf("permissions = %v, want keys.manage", payload.Permissions)
|
||||||
}
|
}
|
||||||
if slices.Contains(payload.Permissions, string(role.ManageUsers)) {
|
if slices.Contains(payload.Permissions, string(role.ManageTenants)) {
|
||||||
t.Fatalf("permissions = %v, must not grant users.manage to an operator", payload.Permissions)
|
t.Fatalf("permissions = %v, must not grant tenants.manage", payload.Permissions)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") {
|
if !strings.HasSuffix(payload.AvatarURL, "/api/v1/avatars/avatar-key") {
|
||||||
t.Fatalf("avatar url = %q", payload.AvatarURL)
|
t.Fatalf("avatar url = %q", payload.AvatarURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClientUserWithoutMembershipIsLeastPrivileged documents the pass-through: a
|
||||||
|
// user with no membership is reported with the permissions of the built-in User
|
||||||
|
// role, which the store resolves, so an empty capability set reaches clients
|
||||||
|
// rather than an error.
|
||||||
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
|
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
|
||||||
server := profileTestServer()
|
server := profileTestServer()
|
||||||
payload := server.clientUser(store.ClientUser{Role: string(role.User)})
|
payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
|
||||||
if payload.AvatarURL != "" {
|
if payload.AvatarURL != "" {
|
||||||
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
|
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
|
||||||
}
|
}
|
||||||
|
|
@ -138,38 +147,6 @@ func TestUpdateProfileRejectsOversizedBody(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestProfilePageIsEmbedded(t *testing.T) {
|
||||||
content, err := web.Files.ReadFile("profile.html")
|
content, err := web.Files.ReadFile("profile.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
151
internal/server/roles.go
Normal file
151
internal/server/roles.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRoleName = 100
|
||||||
|
maxRoleDescription = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// rolesPage serves the role management screen.
|
||||||
|
func (s *Server) rolesPage(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.serveEmbedded(w, "roles.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// listRoles serves the role management screen's data: every built-in role plus
|
||||||
|
// the tenant's own, each annotated with whether the caller may assign it.
|
||||||
|
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := s.requestTenant(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roles, err := s.store.ListRoles(r.Context(), claimsFrom(r).UserID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
s.handleStoreError(w, "list roles", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRole adds a role to the tenant. The store refuses a role that grants
|
||||||
|
// more than the caller holds, so the endpoint does not have to restate that rule.
|
||||||
|
func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := strings.TrimSpace(request.Slug)
|
||||||
|
name := strings.TrimSpace(request.Name)
|
||||||
|
description := strings.TrimSpace(request.Description)
|
||||||
|
if !validSlug(slug) {
|
||||||
|
writeError(w, http.StatusBadRequest, "slug must be lowercase letters, digits, and dashes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if name == "" || len(name) > maxRoleName {
|
||||||
|
writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(description) > maxRoleDescription {
|
||||||
|
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := s.store.CreateRole(
|
||||||
|
r.Context(), claimsFrom(r).UserID, request.TenantID, slug, name, description, request.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrRoleSlugTaken) {
|
||||||
|
writeError(w, http.StatusConflict, "a role with this slug already exists in this tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, store.ErrUnknownPermission) {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.handleStoreError(w, "create role", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateRole renames a role and replaces the permissions it grants.
|
||||||
|
func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(request.Name)
|
||||||
|
description := strings.TrimSpace(request.Description)
|
||||||
|
if name == "" || len(name) > maxRoleName {
|
||||||
|
writeError(w, http.StatusBadRequest, "name must be between 1 and 100 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(description) > maxRoleDescription {
|
||||||
|
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := s.store.UpdateRole(
|
||||||
|
r.Context(), claimsFrom(r).UserID, r.PathValue("id"), name, description, request.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrUnknownPermission) {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.handleStoreError(w, "update role", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteRole removes one of the tenant's own roles.
|
||||||
|
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.store.DeleteRole(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil {
|
||||||
|
if errors.Is(err, store.ErrRoleInUse) {
|
||||||
|
writeError(w, http.StatusConflict, "reassign the members of this role before deleting it")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.handleStoreError(w, "delete role", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestTenant reads the tenant a request targets, preferring an explicit
|
||||||
|
// tenant_id and falling back to the tenant named by the host so that the role
|
||||||
|
// screen works on a tenant subdomain.
|
||||||
|
func (s *Server) requestTenant(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
if tenantID := strings.TrimSpace(r.URL.Query().Get("tenant_id")); tenantID != "" {
|
||||||
|
return tenantID, true
|
||||||
|
}
|
||||||
|
slug := s.requestTenantSlug(r.Host)
|
||||||
|
if slug == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "tenant_id is required")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
tenants, err := s.store.ListTenants(r.Context(), claimsFrom(r).UserID)
|
||||||
|
if err != nil {
|
||||||
|
s.handleStoreError(w, "resolve tenant", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, tenant := range tenants {
|
||||||
|
if tenant.Slug == slug {
|
||||||
|
return tenant.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusNotFound, "resource not found or permission denied")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
177
internal/server/roles_test.go
Normal file
177
internal/server/roles_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/config"
|
||||||
|
"github.com/danyel/go-loose/internal/session"
|
||||||
|
"github.com/danyel/go-loose/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withClaims injects a management session the way requireSession would, so a
|
||||||
|
// handler can be called directly without standing up a session manager.
|
||||||
|
func withClaims(request *http.Request, claims session.Claims) context.Context {
|
||||||
|
return context.WithValue(request.Context(), claimsKey, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesPageIsEmbedded(t *testing.T) {
|
||||||
|
content, err := web.Files.ReadFile("roles.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("roles.html must be embedded: %v", err)
|
||||||
|
}
|
||||||
|
for _, marker := range []string{
|
||||||
|
"role-list", "role-form", "permission-list", "tenant-context", "/assets/roles.js",
|
||||||
|
} {
|
||||||
|
if !bytes.Contains(content, []byte(marker)) {
|
||||||
|
t.Errorf("roles.html is missing %q", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRolesScriptIsServed guards the asset allowlist: roles.js is a new file, and
|
||||||
|
// the handler serves assets by exact name rather than by pattern.
|
||||||
|
func TestRolesScriptIsServed(t *testing.T) {
|
||||||
|
server := profileTestServer()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/roles.js", nil)
|
||||||
|
request.SetPathValue("name", "roles.js")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.asset(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "/api/v1/roles") {
|
||||||
|
t.Error("roles.js does not call the role endpoints")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetAllowlistRejectsUnknownFiles(t *testing.T) {
|
||||||
|
server := profileTestServer()
|
||||||
|
for _, name := range []string{"admin.js", "roles.html", "app.js.map", "secret.env"} {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
|
||||||
|
request.SetPathValue("name", name)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.asset(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAssetTraversalCollapsesToTheAllowlistedName documents that a traversal
|
||||||
|
// attempt cannot escape the allowlist: path.Base reduces it to the bare file
|
||||||
|
// name, which is then matched against the list like any other request.
|
||||||
|
func TestAssetTraversalCollapsesToTheAllowlistedName(t *testing.T) {
|
||||||
|
server := profileTestServer()
|
||||||
|
for _, name := range []string{"../roles.js", "../../etc/passwd", "..%2froles.js"} {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/"+name, nil)
|
||||||
|
request.SetPathValue("name", name)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.asset(response, request)
|
||||||
|
|
||||||
|
served := path.Base(name)
|
||||||
|
if served == "roles.js" {
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Errorf("asset(%q) status = %d, want the allowlisted file to serve", name, response.Code)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if response.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("asset(%q) status = %d, want %d", name, response.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"missing slug", `{"name":"Role","permissions":[]}`},
|
||||||
|
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
|
||||||
|
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
|
||||||
|
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
|
||||||
|
{"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
server := profileTestServer()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/api/v1/roles",
|
||||||
|
strings.NewReader(testCase.body))
|
||||||
|
request.SetPathValue("id", "")
|
||||||
|
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.createRole(response, request)
|
||||||
|
|
||||||
|
// The store would need a database, so a 400 proves validation ran first.
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d (body %s)", response.Code, http.StatusBadRequest, response.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRolesRequiresATenant(t *testing.T) {
|
||||||
|
server := profileTestServer()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/api/v1/roles", nil)
|
||||||
|
request = request.WithContext(withClaims(request, session.Claims{UserID: "user-1", Management: true}))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.listRoles(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "tenant_id") {
|
||||||
|
t.Errorf("body = %s, want it to name the missing parameter", response.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoleRoutesAreRegistered documents the two wrappers the role routes rely
|
||||||
|
// on. The server under test is not installed, so requireSession redirects every
|
||||||
|
// session-guarded route to the installer; an unregistered route would answer 404
|
||||||
|
// instead, which is what makes this a registration check.
|
||||||
|
func TestRoleRoutesAreRegistered(t *testing.T) {
|
||||||
|
// Handler() wraps every route in the request logger, so this server needs a
|
||||||
|
// logger where the profile tests, which call handlers directly, do not.
|
||||||
|
server := &Server{
|
||||||
|
cfg: config.Config{BaseURL: "https://auth.example.test"},
|
||||||
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
}
|
||||||
|
handler := server.Handler()
|
||||||
|
cases := []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api/v1/roles?tenant_id=t"},
|
||||||
|
{http.MethodPost, "/api/v1/roles"},
|
||||||
|
{http.MethodPut, "/api/v1/roles/abc"},
|
||||||
|
{http.MethodDelete, "/api/v1/roles/abc"},
|
||||||
|
{http.MethodGet, "/roles"},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.method+" "+testCase.path, func(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(testCase.method, "https://auth.example.test"+testCase.path, nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want %d from requireSession", response.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
if location := response.Header().Get("Location"); location != "/install" {
|
||||||
|
t.Fatalf("location = %q, want /install", location)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
// 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 /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
|
||||||
|
mux.Handle("GET /roles", s.requireSession(http.HandlerFunc(s.rolesPage)))
|
||||||
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
|
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("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)))
|
||||||
|
|
@ -134,6 +135,10 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
|
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
|
||||||
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
|
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
|
||||||
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
|
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
|
||||||
|
mux.Handle("GET /api/v1/roles", s.requireSession(http.HandlerFunc(s.listRoles)))
|
||||||
|
mux.Handle("POST /api/v1/roles", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createRole))))
|
||||||
|
mux.Handle("PUT /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateRole))))
|
||||||
|
mux.Handle("DELETE /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deleteRole))))
|
||||||
return s.recover(s.logRequests(s.securityHeaders(mux)))
|
return s.recover(s.logRequests(s.securityHeaders(mux)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +197,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" && name != "profile.js" {
|
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +405,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"user": claims, "profile": s.profileResponse(profile),
|
"user": claims, "profile": s.profileResponse(profile),
|
||||||
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
||||||
"users": users, "pending_users": pendingUsers, "roles": role.Catalog(),
|
"users": users, "pending_users": pendingUsers, "permissions": role.Catalog(),
|
||||||
"is_system_administrator": isSystemAdministrator,
|
"is_system_administrator": isSystemAdministrator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -623,16 +628,24 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireSameOrigin rejects state-changing requests that did not come from the
|
||||||
|
// page the session cookie belongs to.
|
||||||
|
//
|
||||||
|
// The comparison is made against the origin the request actually arrived on
|
||||||
|
// rather than against cfg.BaseURL alone. A deployment whose configured base URL
|
||||||
|
// disagrees with the address users browse to -- a reverse proxy terminating TLS,
|
||||||
|
// a tenant subdomain, or simply a stale GO_LOOSE_BASE_URL -- would otherwise
|
||||||
|
// reject every legitimate write. Deriving the scheme from the request keeps the
|
||||||
|
// check anchored to r.Host, which is the part an attacker cannot influence: the
|
||||||
|
// browser sets Origin itself and it always names the page's own host.
|
||||||
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
|
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
source := r.Header.Get("Origin")
|
source := r.Header.Get("Origin")
|
||||||
if source == "" {
|
if source == "" {
|
||||||
source = r.Header.Get("Referer")
|
source = r.Header.Get("Referer")
|
||||||
}
|
}
|
||||||
sourceURL, sourceErr := url.Parse(source)
|
sourceURL, err := url.Parse(source)
|
||||||
baseURL, baseErr := url.Parse(s.cfg.BaseURL)
|
if err != nil || !s.originAllowed(sourceURL, r) {
|
||||||
if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme ||
|
|
||||||
!strings.EqualFold(sourceURL.Host, r.Host) {
|
|
||||||
writeError(w, http.StatusForbidden, "same-origin request required")
|
writeError(w, http.StatusForbidden, "same-origin request required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -640,6 +653,56 @@ func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// originAllowed reports whether source names the same origin as the request.
|
||||||
|
// The configured base URL is accepted as well so that installations which
|
||||||
|
// advertise a canonical address keep working when a request reaches the server
|
||||||
|
// through a different but still legitimate hostname.
|
||||||
|
func (s *Server) originAllowed(source *url.URL, r *http.Request) bool {
|
||||||
|
if source.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
baseScheme, baseHost := baseOrigin(s.cfg.BaseURL)
|
||||||
|
for _, candidate := range [][2]string{
|
||||||
|
{s.requestScheme(r), r.Host},
|
||||||
|
{baseScheme, baseHost},
|
||||||
|
} {
|
||||||
|
scheme, host := candidate[0], candidate[1]
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(source.Host, host) && strings.EqualFold(source.Scheme, scheme) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestScheme reports the scheme the client used to reach this server. A
|
||||||
|
// reverse proxy terminates TLS before the request arrives, so the forwarded
|
||||||
|
// protocol takes precedence over the local connection state.
|
||||||
|
func (s *Server) requestScheme(r *http.Request) string {
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||||
|
if scheme, _, found := strings.Cut(forwarded, ","); found {
|
||||||
|
return strings.TrimSpace(scheme)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(forwarded)
|
||||||
|
}
|
||||||
|
if r.TLS != nil {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseOrigin splits a configured base URL into its scheme and host, returning
|
||||||
|
// empty strings when the value is missing or unparseable.
|
||||||
|
func baseOrigin(baseURL string) (scheme, host string) {
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return parsed.Scheme, parsed.Host
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
|
||||||
|
|
@ -8,28 +8,8 @@ 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"`
|
||||||
|
|
@ -79,7 +59,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Role string `json:"role"`
|
RoleID string `json:"role_id"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if !decodeJSON(w, r, &request) {
|
if !decodeJSON(w, r, &request) {
|
||||||
|
|
@ -95,9 +75,8 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if request.DisplayName == "" {
|
if request.DisplayName == "" {
|
||||||
request.DisplayName = request.Email
|
request.DisplayName = request.Email
|
||||||
}
|
}
|
||||||
invitedRole, ok := invitableRole(request.Role)
|
if strings.TrimSpace(request.RoleID) == "" {
|
||||||
if !ok {
|
writeError(w, http.StatusBadRequest, "role_id is required")
|
||||||
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)
|
||||||
|
|
@ -105,7 +84,7 @@ 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, invitedRole, passwordHash)
|
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.RoleID, passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.handleStoreError(w, "invite user", err)
|
s.handleStoreError(w, "invite user", err)
|
||||||
return
|
return
|
||||||
|
|
@ -117,18 +96,17 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
|
||||||
var request struct {
|
var request struct {
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
Role string `json:"role"`
|
RoleID string `json:"role_id"`
|
||||||
ApplicationIDs []string `json:"application_ids"`
|
ApplicationIDs []string `json:"application_ids"`
|
||||||
}
|
}
|
||||||
if !decodeJSON(w, r, &request) {
|
if !decodeJSON(w, r, &request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
membershipRole, ok := assignableRole(request.Role)
|
if strings.TrimSpace(request.RoleID) == "" {
|
||||||
if !ok {
|
writeError(w, http.StatusBadRequest, "role_id is required")
|
||||||
writeError(w, http.StatusBadRequest, "invalid role")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), membershipRole, request.ApplicationIDs); err != nil {
|
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.RoleID, request.ApplicationIDs); err != nil {
|
||||||
s.handleStoreError(w, "update user access", err)
|
s.handleStoreError(w, "update user access", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -138,20 +116,19 @@ func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
|
||||||
var request struct {
|
var request struct {
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
Role string `json:"role"`
|
RoleID string `json:"role_id"`
|
||||||
ApplicationIDs []string `json:"application_ids"`
|
ApplicationIDs []string `json:"application_ids"`
|
||||||
}
|
}
|
||||||
if !decodeJSON(w, r, &request) {
|
if !decodeJSON(w, r, &request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
membershipRole, ok := assignableRole(request.Role)
|
if strings.TrimSpace(request.RoleID) == "" {
|
||||||
if !ok {
|
writeError(w, http.StatusBadRequest, "role_id is required")
|
||||||
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"),
|
||||||
membershipRole, request.ApplicationIDs,
|
request.RoleID, request.ApplicationIDs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
s.handleStoreError(w, "approve pending user", err)
|
s.handleStoreError(w, "approve pending user", err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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"`
|
||||||
|
RoleID string `json:"role_id"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ApplicationIDs []string `json:"application_ids"`
|
ApplicationIDs []string `json:"application_ids"`
|
||||||
|
|
@ -47,6 +48,8 @@ type ClientUser struct {
|
||||||
// without a membership is reported as User so clients always see the least
|
// without a membership is reported as User so clients always see the least
|
||||||
// privileged role.
|
// privileged role.
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
// Permissions is read from the role's own rows. A user without a membership
|
||||||
|
// gets the permissions of the built-in User role.
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
// AvatarKey is the stored locator; the server expands it into AvatarURL.
|
// AvatarKey is the stored locator; the server expands it into AvatarURL.
|
||||||
AvatarKey *string `json:"-"`
|
AvatarKey *string `json:"-"`
|
||||||
|
|
@ -55,9 +58,13 @@ type ClientUser struct {
|
||||||
|
|
||||||
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, r.slug, m.role_id,
|
||||||
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,
|
u.avatar_key,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(rp.permission ORDER BY rp.permission)
|
||||||
|
FROM role_permissions rp WHERE rp.role_id = m.role_id
|
||||||
|
), '[]'::json),
|
||||||
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
|
||||||
|
|
@ -66,12 +73,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
|
||||||
), '[]'::json)
|
), '[]'::json)
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN memberships m ON m.user_id = u.id
|
JOIN memberships m ON m.user_id = u.id
|
||||||
WHERE EXISTS (
|
JOIN roles r ON r.id = m.role_id
|
||||||
SELECT 1 FROM memberships own
|
WHERE `+permissionPredicate("m.tenant_id", "$1", "$2")+`
|
||||||
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
|
ORDER BY u.email, m.tenant_id`, administratorID, string(role.ManageUsers))
|
||||||
AND own.role = ANY($2)
|
|
||||||
)
|
|
||||||
ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -79,37 +83,42 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
|
||||||
result := make([]ManagedUser, 0)
|
result := make([]ManagedUser, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user ManagedUser
|
var user ManagedUser
|
||||||
var applicationIDs []byte
|
var permissions, applicationIDs []byte
|
||||||
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
|
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
|
||||||
&user.Status, &user.AvatarKey, &applicationIDs); err != nil {
|
&user.RoleID, &user.Status, &user.AvatarKey, &permissions, &applicationIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permissions, &user.Permissions); 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, membershipRole, passwordHash string) (ManagedUser, error) {
|
// InviteUser adds a member to a tenant or, when the email already exists,
|
||||||
|
// re-invites them. The caller must hold users.manage in that tenant and may only
|
||||||
|
// hand out a role whose permissions they hold themselves.
|
||||||
|
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, roleID string, 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
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var permitted bool
|
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
|
||||||
if err := tx.QueryRowContext(ctx, `
|
if err != nil {
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM memberships
|
|
||||||
WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3)
|
|
||||||
)`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil {
|
|
||||||
return ManagedUser{}, err
|
return ManagedUser{}, err
|
||||||
}
|
}
|
||||||
if !permitted {
|
if !within([]string{string(role.ManageUsers)}, held) {
|
||||||
return ManagedUser{}, ErrNotFound
|
return ManagedUser{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
|
||||||
|
if err != nil {
|
||||||
|
return ManagedUser{}, err
|
||||||
|
}
|
||||||
var user ManagedUser
|
var user ManagedUser
|
||||||
err = tx.QueryRowContext(ctx, `
|
err = tx.QueryRowContext(ctx, `
|
||||||
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
|
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
|
||||||
|
|
@ -125,38 +134,66 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
|
||||||
return ManagedUser{}, err
|
return ManagedUser{}, err
|
||||||
}
|
}
|
||||||
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_id) 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_id = EXCLUDED.role_id`,
|
||||||
tenantID, user.ID, membershipRole); err != nil {
|
tenantID, user.ID, resolved); 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, membershipRole, []string{}
|
granted, err := roleGrants(ctx, s.db, resolved)
|
||||||
user.Permissions = role.Role(membershipRole).Permissions()
|
if err != nil {
|
||||||
|
return ManagedUser{}, err
|
||||||
|
}
|
||||||
|
user.TenantID, user.RoleID, user.ApplicationIDs = tenantID, resolved, []string{}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT slug FROM roles WHERE id = $1`, resolved).Scan(&user.Role); err != nil {
|
||||||
|
return ManagedUser{}, err
|
||||||
|
}
|
||||||
|
user.Permissions = granted
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error {
|
// SetUserAccess changes a member's role and replaces their application grants.
|
||||||
|
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, roleID 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
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !within([]string{string(role.ManageUsers)}, held) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureTenantKeepsAnOwner(ctx, tx, tenantID, userID, resolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
result, err := tx.ExecContext(ctx, `
|
result, err := tx.ExecContext(ctx, `
|
||||||
UPDATE memberships target SET role = $4
|
UPDATE memberships SET role_id = $3
|
||||||
FROM memberships administrator
|
WHERE tenant_id = $1 AND user_id = $2 AND role_id IS DISTINCT FROM $3`,
|
||||||
WHERE target.tenant_id = $1 AND target.user_id = $2
|
tenantID, userID, resolved)
|
||||||
AND administrator.tenant_id = target.tenant_id
|
|
||||||
AND administrator.user_id = $3 AND administrator.role = ANY($5)`,
|
|
||||||
tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count, _ := result.RowsAffected(); count == 0 {
|
if count, _ := result.RowsAffected(); count == 0 {
|
||||||
|
// Either the membership does not exist or the role is unchanged.
|
||||||
|
var exists bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)`,
|
||||||
|
tenantID, userID).Scan(&exists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
DELETE FROM user_application_access ua
|
DELETE FROM user_application_access ua
|
||||||
USING applications a
|
USING applications a
|
||||||
|
|
@ -179,17 +216,49 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrLastOwner is returned when a change would leave a tenant without an owner.
|
||||||
|
var ErrLastOwner = errors.New("a tenant must keep at least one owner")
|
||||||
|
|
||||||
|
// ensureTenantKeepsAnOwner blocks demoting or moving away the only membership
|
||||||
|
// that still holds the owner role. Without this a tenant administrator could
|
||||||
|
// lock every administrator out of their own tenant with a single edit.
|
||||||
|
func ensureTenantKeepsAnOwner(ctx context.Context, tx *sql.Tx, tenantID, userID, newRoleID string) error {
|
||||||
|
var losesOwner bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM memberships m
|
||||||
|
JOIN roles r ON r.id = m.role_id
|
||||||
|
WHERE m.tenant_id = $1 AND m.user_id = $2 AND r.slug = 'owner' AND m.role_id <> $3
|
||||||
|
)`, tenantID, userID, newRoleID).Scan(&losesOwner); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !losesOwner {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var remaining int
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT count(*)
|
||||||
|
FROM memberships m
|
||||||
|
JOIN roles r ON r.id = m.role_id
|
||||||
|
WHERE m.tenant_id = $1 AND r.slug = 'owner' AND m.user_id <> $2`,
|
||||||
|
tenantID, userID).Scan(&remaining); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
return ErrLastOwner
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
|
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
|
||||||
var app Application
|
var app Application
|
||||||
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 = $4, client_secret_hash = $5, updated_at = now()
|
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
|
||||||
FROM memberships m
|
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$2", "$3")+`
|
||||||
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
|
|
||||||
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, role.Grants(role.ManageApplications), redirectURIs, secretHash).
|
applicationID, administratorID, string(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
|
||||||
|
|
@ -263,6 +332,7 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var user ClientUser
|
var user ClientUser
|
||||||
|
var permissions []byte
|
||||||
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
|
FROM applications a
|
||||||
|
|
@ -270,21 +340,25 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
|
||||||
JOIN user_application_access ua ON ua.application_id = a.id
|
JOIN user_application_access ua ON ua.application_id = a.id
|
||||||
JOIN users u ON u.id = ua.user_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
|
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
|
||||||
|
LEFT JOIN roles r ON r.id = m.role_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 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
|
||||||
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`,
|
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
|
||||||
codeHash, redirectURI, clientID, secretHash, role.User).
|
codeHash, redirectURI, clientID, secretHash).
|
||||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
|
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
|
||||||
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
|
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ClientUser{}, time.Time{}, ErrNotFound
|
return ClientUser{}, time.Time{}, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ClientUser{}, time.Time{}, err
|
return ClientUser{}, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
|
||||||
|
return ClientUser{}, time.Time{}, err
|
||||||
|
}
|
||||||
expiresAt := time.Now().UTC().Add(12 * time.Hour)
|
expiresAt := time.Now().UTC().Add(12 * time.Hour)
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
|
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
|
||||||
|
|
@ -296,6 +370,7 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
|
||||||
|
|
||||||
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
|
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
|
||||||
var user ClientUser
|
var user ClientUser
|
||||||
|
var permissions []byte
|
||||||
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
|
FROM applications a
|
||||||
|
|
@ -303,17 +378,24 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client
|
||||||
JOIN user_application_access ua ON ua.application_id = a.id
|
JOIN user_application_access ua ON ua.application_id = a.id
|
||||||
JOIN users u ON u.id = ua.user_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
|
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
|
||||||
|
LEFT JOIN roles r ON r.id = m.role_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 ua.user_id = s.user_id
|
AND a.id = s.application_id AND ua.user_id = s.user_id
|
||||||
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
|
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
|
||||||
COALESCE(m.role, $2), u.avatar_key`,
|
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
|
||||||
tokenHash, role.User).
|
tokenHash).
|
||||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
|
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
|
||||||
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
|
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ClientUser{}, ErrNotFound
|
return ClientUser{}, ErrNotFound
|
||||||
}
|
}
|
||||||
return user, err
|
if err != nil {
|
||||||
|
return ClientUser{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
|
||||||
|
return ClientUser{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {
|
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,11 @@ 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, $3)
|
INSERT INTO memberships(tenant_id, user_id, role_id)
|
||||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
SELECT $1, $2, r.id FROM roles r
|
||||||
tenantID, userID, role.Admin); err != nil {
|
WHERE r.slug = $3 AND r.tenant_id IS NULL
|
||||||
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
|
||||||
|
tenantID, userID, "admin"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
|
@ -115,8 +117,10 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
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_id)
|
||||||
SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil {
|
SELECT id, $1, r.id FROM tenants, roles r
|
||||||
|
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
|
||||||
|
ON CONFLICT DO NOTHING`, userID); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
|
@ -128,14 +132,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasConsoleAccess reports whether the user may read the management console.
|
// HasConsoleAccess reports whether the user may read the management console.
|
||||||
// System administrators always qualify; otherwise the membership role must
|
// System administrators always qualify; otherwise one of their memberships must
|
||||||
// grant console.read, which excludes self-service User members.
|
// grant console.read, which excludes self-service User members.
|
||||||
func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) {
|
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 AND role = ANY($2))`,
|
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
|
||||||
userID, role.Grants(role.ReadConsole)).Scan(&allowed)
|
userID, string(role.ReadConsole)).Scan(&allowed)
|
||||||
return allowed, err
|
return allowed, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +187,11 @@ func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
|
// ApprovePendingUser gives a waiting-room visitor their first membership.
|
||||||
|
// Only a system administrator may do this, so the escalation guard that
|
||||||
|
// normally limits role assignment does not apply: the administrator is acting
|
||||||
|
// across tenants rather than inside one.
|
||||||
|
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
|
||||||
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
|
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -196,11 +204,17 @@ func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
result, err := tx.ExecContext(ctx, `
|
result, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO memberships(tenant_id, user_id, role)
|
INSERT INTO memberships(tenant_id, user_id, role_id)
|
||||||
SELECT $1, id, $3 FROM users WHERE id = $2
|
SELECT $1, id, $3 FROM users WHERE id = $2
|
||||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
|
||||||
tenantID, userID, role)
|
tenantID, userID, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,14 @@ func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug st
|
||||||
return tenant
|
return tenant
|
||||||
}
|
}
|
||||||
|
|
||||||
// addMember invites a member into the tenant with the given role.
|
// addMember invites a member into the tenant with the given built-in role.
|
||||||
func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string {
|
func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
member, err := s.InviteUser(ctx, owner, tenantID, email, email, membershipRole, "not-a-real-hash")
|
roleID, err := s.SystemRoleID(ctx, membershipRole)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up role %q: %v", membershipRole, err)
|
||||||
|
}
|
||||||
|
member, err := s.InviteUser(ctx, owner, tenantID, email, email, roleID, "not-a-real-hash")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("invite %s as %s: %v", email, membershipRole, err)
|
t.Fatalf("invite %s as %s: %v", email, membershipRole, err)
|
||||||
}
|
}
|
||||||
|
|
@ -124,21 +128,21 @@ func TestMigrationsApplyAndRolesAreAccepted(t *testing.T) {
|
||||||
s, _, ctx := integrationStore(t)
|
s, _, ctx := integrationStore(t)
|
||||||
owner := newUser(t, s, ctx, "owner@example.test")
|
owner := newUser(t, s, ctx, "owner@example.test")
|
||||||
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
||||||
for _, item := range []role.Role{role.Developer, role.Operator, role.User} {
|
for _, item := range []string{"developer", "operator", "user"} {
|
||||||
addMember(t, s, ctx, owner, tenant.ID, string(item)+"@example.test", string(item))
|
addMember(t, s, ctx, owner, tenant.ID, item+"@example.test", item)
|
||||||
}
|
}
|
||||||
if _, err := s.ListTenants(ctx, owner); err != nil {
|
if _, err := s.ListTenants(ctx, owner); err != nil {
|
||||||
t.Fatalf("list tenants: %v", err)
|
t.Fatalf("list tenants: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMembershipRoleConstraintRejectsUnknownRole(t *testing.T) {
|
func TestUnknownRoleIDIsRefused(t *testing.T) {
|
||||||
s, _, ctx := integrationStore(t)
|
s, _, ctx := integrationStore(t)
|
||||||
owner := newUser(t, s, ctx, "owner@example.test")
|
owner := newUser(t, s, ctx, "owner@example.test")
|
||||||
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
||||||
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Viewer))
|
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
|
||||||
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "superuser", nil); err == nil {
|
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "00000000-0000-0000-0000-000000000000", nil); err == nil {
|
||||||
t.Fatal("the database must reject a role outside the permission matrix")
|
t.Fatal("assigning a role that does not exist must be refused")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,24 +157,26 @@ func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) {
|
||||||
t.Fatalf("owner cannot create an application: %v", err)
|
t.Fatalf("owner cannot create an application: %v", err)
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
role role.Role
|
role string
|
||||||
canCreateApp bool
|
canCreateApp bool
|
||||||
canIssueKey bool
|
canIssueKey bool
|
||||||
canSaveContract bool
|
canSaveContract bool
|
||||||
canListManaged bool
|
canListManaged bool
|
||||||
canConfigureClien bool
|
canConfigureClien bool
|
||||||
}{
|
}{
|
||||||
{role: role.Owner, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
|
// owner and admin administer members; developer and operator may also
|
||||||
{role: role.Admin, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
|
// maintain their own tenant's members, which is why they can list them.
|
||||||
{role: role.Developer, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: true},
|
{role: "owner", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
|
||||||
{role: role.Operator, canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: false},
|
{role: "admin", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
|
||||||
{role: role.Viewer, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
|
{role: "developer", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
|
||||||
{role: role.User, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
|
{role: "operator", canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: false},
|
||||||
|
{role: "viewer", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
|
||||||
|
{role: "user", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(string(test.role), func(t *testing.T) {
|
t.Run(test.role, func(t *testing.T) {
|
||||||
member := addMember(t, s, ctx, owner, tenant.ID, string(test.role)+"@example.test", string(test.role))
|
member := addMember(t, s, ctx, owner, tenant.ID, test.role+"@example.test", test.role)
|
||||||
slug := strings.ReplaceAll(string(test.role), "owner", "own")
|
slug := strings.ReplaceAll(test.role, "owner", "own")
|
||||||
|
|
||||||
_, err := s.CreateApplication(ctx, member, store.Application{
|
_, err := s.CreateApplication(ctx, member, store.Application{
|
||||||
TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{},
|
TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{},
|
||||||
|
|
@ -201,8 +207,8 @@ func TestConsoleAccessExcludesSelfServiceMembers(t *testing.T) {
|
||||||
s, _, ctx := integrationStore(t)
|
s, _, ctx := integrationStore(t)
|
||||||
owner := newUser(t, s, ctx, "owner@example.test")
|
owner := newUser(t, s, ctx, "owner@example.test")
|
||||||
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
||||||
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", string(role.User))
|
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", "user")
|
||||||
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", string(role.Operator))
|
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", "operator")
|
||||||
waiting := newUser(t, s, ctx, "waiting@example.test")
|
waiting := newUser(t, s, ctx, "waiting@example.test")
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
|
|
@ -239,20 +245,25 @@ func TestTenantPermissionsComeFromTheRoleMatrix(t *testing.T) {
|
||||||
s, _, ctx := integrationStore(t)
|
s, _, ctx := integrationStore(t)
|
||||||
owner := newUser(t, s, ctx, "owner@example.test")
|
owner := newUser(t, s, ctx, "owner@example.test")
|
||||||
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
||||||
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", string(role.Developer))
|
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", "developer")
|
||||||
|
|
||||||
tenants, err := s.ListTenants(ctx, developer)
|
tenants, err := s.ListTenants(ctx, developer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list tenants: %v", err)
|
t.Fatalf("list tenants: %v", err)
|
||||||
}
|
}
|
||||||
if len(tenants) != 1 || tenants[0].Role != string(role.Developer) {
|
if len(tenants) != 1 || tenants[0].Role != "developer" {
|
||||||
t.Fatalf("tenants = %+v", tenants)
|
t.Fatalf("tenants = %+v", tenants)
|
||||||
}
|
}
|
||||||
if !contains(tenants[0].Permissions, string(role.ManageApplications)) {
|
if !contains(tenants[0].Permissions, string(role.ManageApplications)) {
|
||||||
t.Fatalf("developer permissions = %v", tenants[0].Permissions)
|
t.Fatalf("developer permissions = %v", tenants[0].Permissions)
|
||||||
}
|
}
|
||||||
if contains(tenants[0].Permissions, string(role.ManageUsers)) {
|
// developer administers members but must not mint roles, which is what
|
||||||
t.Fatalf("developer permissions = %v, must not manage users", tenants[0].Permissions)
|
// defines privilege in the first place.
|
||||||
|
if !contains(tenants[0].Permissions, string(role.ManageUsers)) {
|
||||||
|
t.Fatalf("developer permissions = %v, want users.manage", tenants[0].Permissions)
|
||||||
|
}
|
||||||
|
if contains(tenants[0].Permissions, string(role.ManageRoles)) {
|
||||||
|
t.Fatalf("developer permissions = %v, must not manage roles", tenants[0].Permissions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,7 +271,7 @@ func TestProfileRoundTrip(t *testing.T) {
|
||||||
s, _, ctx := integrationStore(t)
|
s, _, ctx := integrationStore(t)
|
||||||
owner := newUser(t, s, ctx, "owner@example.test")
|
owner := newUser(t, s, ctx, "owner@example.test")
|
||||||
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
||||||
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator))
|
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
|
||||||
|
|
||||||
before, err := s.Profile(ctx, member)
|
before, err := s.Profile(ctx, member)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -269,7 +280,7 @@ func TestProfileRoundTrip(t *testing.T) {
|
||||||
if before.AvatarKey != nil || before.NameCustomized {
|
if before.AvatarKey != nil || before.NameCustomized {
|
||||||
t.Fatalf("fresh profile = %+v", before)
|
t.Fatalf("fresh profile = %+v", before)
|
||||||
}
|
}
|
||||||
if len(before.Memberships) != 1 || before.Memberships[0].Role != string(role.Operator) {
|
if len(before.Memberships) != 1 || before.Memberships[0].Role != "operator" {
|
||||||
t.Fatalf("memberships = %+v", before.Memberships)
|
t.Fatalf("memberships = %+v", before.Memberships)
|
||||||
}
|
}
|
||||||
if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) {
|
if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) {
|
||||||
|
|
@ -329,7 +340,7 @@ func TestPictureIsStoredAndReadableByKey(t *testing.T) {
|
||||||
s, _, ctx := integrationStore(t)
|
s, _, ctx := integrationStore(t)
|
||||||
owner := newUser(t, s, ctx, "owner@example.test")
|
owner := newUser(t, s, ctx, "owner@example.test")
|
||||||
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
tenant := newTenant(t, s, ctx, owner, "nmbs")
|
||||||
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Viewer))
|
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
|
||||||
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
|
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
|
||||||
payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
|
payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
|
||||||
|
|
||||||
|
|
@ -425,7 +436,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create application: %v", err)
|
t.Fatalf("create application: %v", err)
|
||||||
}
|
}
|
||||||
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator))
|
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
|
||||||
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil {
|
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil {
|
||||||
t.Fatalf("configure client: %v", err)
|
t.Fatalf("configure client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -443,7 +454,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("exchange authorization code: %v", err)
|
t.Fatalf("exchange authorization code: %v", err)
|
||||||
}
|
}
|
||||||
if user.Role != string(role.Operator) {
|
if user.Role != "operator" {
|
||||||
t.Fatalf("role = %q, want operator", user.Role)
|
t.Fatalf("role = %q, want operator", user.Role)
|
||||||
}
|
}
|
||||||
if user.AvatarKey == nil {
|
if user.AvatarKey == nil {
|
||||||
|
|
@ -453,7 +464,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("client user by token: %v", err)
|
t.Fatalf("client user by token: %v", err)
|
||||||
}
|
}
|
||||||
if byToken.Role != string(role.Operator) || byToken.AvatarKey == nil {
|
if byToken.Role != "operator" || byToken.AvatarKey == nil {
|
||||||
t.Fatalf("client user = %+v", byToken)
|
t.Fatalf("client user = %+v", byToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -480,8 +491,8 @@ func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("exchange authorization code: %v", err)
|
t.Fatalf("exchange authorization code: %v", err)
|
||||||
}
|
}
|
||||||
if user.Role != string(role.User) {
|
if user.Role != "user" {
|
||||||
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, role.User)
|
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, "user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ package store
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danyel/go-loose/internal/role"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Membership is one tenant role held by a user.
|
// Membership is one tenant role held by a user.
|
||||||
|
|
@ -15,6 +14,7 @@ type Membership struct {
|
||||||
TenantSlug string `json:"tenant_slug"`
|
TenantSlug string `json:"tenant_slug"`
|
||||||
TenantName string `json:"tenant_name"`
|
TenantName string `json:"tenant_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
RoleID string `json:"role_id"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,9 +77,14 @@ func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
|
||||||
}
|
}
|
||||||
profile.Memberships = []Membership{}
|
profile.Memberships = []Membership{}
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT m.tenant_id, t.slug, t.name, m.role
|
SELECT m.tenant_id, t.slug, t.name, r.slug, m.role_id,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(rp.permission ORDER BY rp.permission)
|
||||||
|
FROM role_permissions rp WHERE rp.role_id = m.role_id
|
||||||
|
), '[]'::json)
|
||||||
FROM memberships m
|
FROM memberships m
|
||||||
JOIN tenants t ON t.id = m.tenant_id
|
JOIN tenants t ON t.id = m.tenant_id
|
||||||
|
JOIN roles r ON r.id = m.role_id
|
||||||
WHERE m.user_id = $1
|
WHERE m.user_id = $1
|
||||||
ORDER BY t.name`, userID)
|
ORDER BY t.name`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -88,10 +93,14 @@ func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item Membership
|
var item Membership
|
||||||
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role); err != nil {
|
var permissions []byte
|
||||||
|
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role,
|
||||||
|
&item.RoleID, &permissions); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permissions, &item.Permissions); err != nil {
|
||||||
return Profile{}, err
|
return Profile{}, err
|
||||||
}
|
}
|
||||||
item.Permissions = role.Role(item.Role).Permissions()
|
|
||||||
profile.Memberships = append(profile.Memberships, item)
|
profile.Memberships = append(profile.Memberships, item)
|
||||||
}
|
}
|
||||||
return profile, rows.Err()
|
return profile, rows.Err()
|
||||||
|
|
|
||||||
455
internal/store/roles.go
Normal file
455
internal/store/roles.go
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role is a named bundle of permissions. A role with no TenantID is built in:
|
||||||
|
// every tenant sees it and it cannot be edited or deleted. A role with a
|
||||||
|
// TenantID belongs to that tenant alone.
|
||||||
|
type Role struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID *string `json:"tenant_id,omitempty"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
System bool `json:"system"`
|
||||||
|
// Permissions is sorted so that the console can compare two roles without
|
||||||
|
// worrying about the order they were stored in.
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
// Assignable reports whether the requesting administrator may hand this role
|
||||||
|
// to somebody. A role is assignable only when every permission it grants is
|
||||||
|
// one the administrator already holds, so managing members can never be used
|
||||||
|
// to grant more authority than the administrator has.
|
||||||
|
Assignable bool `json:"assignable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrRoleInUse is returned when a role that still has members is deleted.
|
||||||
|
ErrRoleInUse = errors.New("role is still assigned to members")
|
||||||
|
// ErrRoleSlugTaken is returned when a tenant already uses the requested slug.
|
||||||
|
ErrRoleSlugTaken = errors.New("role slug is already in use")
|
||||||
|
// ErrUnknownPermission is returned when a role is defined with a permission
|
||||||
|
// outside the vocabulary. It is a caller mistake rather than a server fault,
|
||||||
|
// so callers report it as a bad request.
|
||||||
|
ErrUnknownPermission = errors.New("unknown permission")
|
||||||
|
)
|
||||||
|
|
||||||
|
// querier is satisfied by both *sql.DB and *sql.Tx so that the authorization
|
||||||
|
// helpers can run inside or outside a transaction.
|
||||||
|
type querier interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionPredicate renders the one authorization check used by every
|
||||||
|
// tenant-scoped statement: it holds when the user holds permission in the
|
||||||
|
// tenant. The arguments are SQL expressions rather than placeholders because the
|
||||||
|
// queries that need it have already joined memberships under a name of their
|
||||||
|
// own. Resolving the grant through role_permissions is what lets a tenant define
|
||||||
|
// its own roles without any Go code having to know they exist.
|
||||||
|
func permissionPredicate(tenantExpr, userExpr, permissionExpr string) string {
|
||||||
|
return fmt.Sprintf(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM memberships scoped
|
||||||
|
JOIN role_permissions granted ON granted.role_id = scoped.role_id
|
||||||
|
WHERE scoped.tenant_id = %s AND scoped.user_id = %s
|
||||||
|
AND granted.permission = %s
|
||||||
|
)`, tenantExpr, userExpr, permissionExpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemRoleID returns the id of a built-in role. Callers that seed memberships
|
||||||
|
// outside a tenant context -- the installer, the bootstrapper -- need it because
|
||||||
|
// a membership is a role id rather than a name.
|
||||||
|
func (s *Store) SystemRoleID(ctx context.Context, slug string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = $1 AND tenant_id IS NULL`, slug).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientPermissions is the permission aggregate reported to client applications
|
||||||
|
// through /connect/userinfo. A user with no membership in the owning tenant is
|
||||||
|
// described by the built-in User role, which is the least privileged one.
|
||||||
|
const clientPermissions = `COALESCE(
|
||||||
|
(SELECT json_agg(rp.permission ORDER BY rp.permission)
|
||||||
|
FROM role_permissions rp WHERE rp.role_id = m.role_id),
|
||||||
|
(SELECT json_agg(rp.permission ORDER BY rp.permission)
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN roles fallback ON fallback.id = rp.role_id
|
||||||
|
WHERE fallback.slug = 'user' AND fallback.tenant_id IS NULL),
|
||||||
|
'[]'::json)`
|
||||||
|
|
||||||
|
// heldPermissions returns every permission the actor holds in a tenant, sorted.
|
||||||
|
// It is the basis for both the per-action checks and the escalation guard: an
|
||||||
|
// actor may never hand out a capability they do not have themselves.
|
||||||
|
func heldPermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT granted.permission
|
||||||
|
FROM memberships scoped
|
||||||
|
JOIN role_permissions granted ON granted.role_id = scoped.role_id
|
||||||
|
WHERE scoped.tenant_id = $1 AND scoped.user_id = $2
|
||||||
|
ORDER BY granted.permission`, tenantID, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
held := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var permission string
|
||||||
|
if err := rows.Scan(&permission); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
held = append(held, permission)
|
||||||
|
}
|
||||||
|
return held, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPermission reports whether the actor holds one permission in a tenant.
|
||||||
|
func hasPermission(ctx context.Context, q querier, actorID, tenantID string, permission role.Permission) (bool, error) {
|
||||||
|
var permitted bool
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
`SELECT `+permissionPredicate("$2", "$1", "$3"),
|
||||||
|
actorID, tenantID, string(permission)).Scan(&permitted)
|
||||||
|
return permitted, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// within reports whether every permission in granted also appears in held.
|
||||||
|
func within(granted, held []string) bool {
|
||||||
|
available := make(map[string]bool, len(held))
|
||||||
|
for _, permission := range held {
|
||||||
|
available[permission] = true
|
||||||
|
}
|
||||||
|
for _, permission := range granted {
|
||||||
|
if !available[permission] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePermissions validates submitted permission names and returns them
|
||||||
|
// deduplicated in vocabulary order, so a role cannot be stored with an invented
|
||||||
|
// capability or an unstable order.
|
||||||
|
func normalizePermissions(permissions []string) ([]string, error) {
|
||||||
|
requested := make(map[string]bool, len(permissions))
|
||||||
|
for _, value := range permissions {
|
||||||
|
parsed, ok := role.Parse(value)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, value)
|
||||||
|
}
|
||||||
|
requested[string(parsed)] = true
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(requested))
|
||||||
|
for _, permission := range role.Permissions() {
|
||||||
|
if requested[string(permission)] {
|
||||||
|
result = append(result, string(permission))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleGrants reads a role's permissions.
|
||||||
|
func roleGrants(ctx context.Context, q querier, roleID string) ([]string, error) {
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT permission FROM role_permissions WHERE role_id = $1 ORDER BY permission`, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var permission string
|
||||||
|
if err := rows.Scan(&permission); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, permission)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRole(row scanner) (Role, error) {
|
||||||
|
var item Role
|
||||||
|
var tenantID sql.NullString
|
||||||
|
if err := row.Scan(&item.ID, &tenantID, &item.Slug, &item.Name, &item.Description,
|
||||||
|
&item.System, &item.MemberCount); err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
if tenantID.Valid {
|
||||||
|
item.TenantID = &tenantID.String
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleSelect = `
|
||||||
|
SELECT r.id, r.tenant_id, r.slug, r.name, r.description, r.is_system,
|
||||||
|
(SELECT count(*) FROM memberships m WHERE m.role_id = r.id)
|
||||||
|
FROM roles r `
|
||||||
|
|
||||||
|
// ListRoles returns the roles available in a tenant: every built-in role plus
|
||||||
|
// that tenant's own. Reading the catalog needs users.read so the role screen can
|
||||||
|
// describe a role before anybody is assigned it.
|
||||||
|
func (s *Store) ListRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) {
|
||||||
|
permitted, err := hasPermission(ctx, s.db, actorID, tenantID, role.ReadUsers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !permitted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
roles, err := s.rolesIn(ctx, s.db, actorID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignableRoles returns only the roles the administrator may hand to a member.
|
||||||
|
func (s *Store) AssignableRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) {
|
||||||
|
roles, err := s.ListRoles(ctx, actorID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]Role, 0, len(roles))
|
||||||
|
for _, item := range roles {
|
||||||
|
if item.Assignable {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rolesIn loads the tenant's catalog and marks what the actor may assign.
|
||||||
|
func (s *Store) rolesIn(ctx context.Context, q querier, actorID, tenantID string) ([]Role, error) {
|
||||||
|
rows, err := q.QueryContext(ctx, roleSelect+`
|
||||||
|
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
|
||||||
|
ORDER BY r.is_system DESC,
|
||||||
|
array_position(ARRAY['owner','admin','developer','operator','viewer','user'], r.slug) NULLS LAST,
|
||||||
|
r.name`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
held, err := heldPermissions(ctx, q, actorID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]Role, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanRole(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
permissions, err := roleGrants(ctx, q, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Permissions = permissions
|
||||||
|
item.Assignable = within(permissions, held)
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleByID(ctx context.Context, q querier, roleID string) (Role, error) {
|
||||||
|
item, err := scanRole(q.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1`, roleID))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Role{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole adds a tenant's own role. The caller must hold roles.manage and may
|
||||||
|
// not mint a role more powerful than one they already hold.
|
||||||
|
func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, description string, permissions []string) (Role, error) {
|
||||||
|
granted, err := normalizePermissions(permissions)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
held, err := heldPermissions(ctx, tx, actorID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
|
||||||
|
return Role{}, ErrNotFound
|
||||||
|
}
|
||||||
|
created, err := insertRole(ctx, tx, tenantID, slug, name, description)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
for _, permission := range granted {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`,
|
||||||
|
created.ID, permission); err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created.Permissions = granted
|
||||||
|
created.Assignable = true
|
||||||
|
return created, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole renames a role and replaces its permissions. Built-in roles are
|
||||||
|
// immutable and a tenant may only edit a role it owns.
|
||||||
|
func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, description string, permissions []string) (Role, error) {
|
||||||
|
granted, err := normalizePermissions(permissions)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
existing, err := lockRole(ctx, tx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
if existing.System || existing.TenantID == nil {
|
||||||
|
return Role{}, ErrNotFound
|
||||||
|
}
|
||||||
|
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
|
||||||
|
return Role{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE roles SET name = $2, description = $3, updated_at = now() WHERE id = $1`,
|
||||||
|
roleID, name, description); err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = $1`, roleID); err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
for _, permission := range granted {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`,
|
||||||
|
roleID, permission); err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.Name, existing.Description, existing.Permissions = name, description, granted
|
||||||
|
return existing, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole removes a tenant's own role. Built-in roles and roles that still
|
||||||
|
// have members cannot be deleted, so nobody is left pointing at nothing.
|
||||||
|
func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
existing, err := lockRole(ctx, tx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing.System || existing.TenantID == nil {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
permissions, err := roleGrants(ctx, tx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !within([]string{string(role.ManageRoles)}, held) || !within(permissions, held) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if existing.MemberCount > 0 {
|
||||||
|
return ErrRoleInUse
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRoleID validates that a role may be assigned to a member of the tenant
|
||||||
|
// and returns its id. A role belonging to another tenant is reported as not
|
||||||
|
// found so that role ids cannot be probed across tenants.
|
||||||
|
func resolveRoleID(ctx context.Context, tx *sql.Tx, tenantID, roleID string, held []string) (string, error) {
|
||||||
|
item, err := roleByID(ctx, tx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if item.TenantID != nil && *item.TenantID != tenantID {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
granted, err := roleGrants(ctx, tx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !within(granted, held) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return roleID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockRole loads a role and holds the row for the rest of the transaction.
|
||||||
|
func lockRole(ctx context.Context, tx *sql.Tx, roleID string) (Role, error) {
|
||||||
|
item, err := scanRole(tx.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1 FOR UPDATE`, roleID))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Role{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
permissions, err := roleGrants(ctx, tx, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
item.Permissions = permissions
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertRole writes the role row, translating a slug collision into a stable
|
||||||
|
// error rather than a driver-specific constraint violation.
|
||||||
|
func insertRole(ctx context.Context, tx *sql.Tx, tenantID, slug, name, description string) (Role, error) {
|
||||||
|
item, err := scanRole(tx.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO roles(tenant_id, slug, name, description)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, tenant_id, slug, name, description, is_system, 0`,
|
||||||
|
tenantID, slug, name, description))
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return Role{}, ErrRoleSlugTaken
|
||||||
|
}
|
||||||
|
return Role{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "duplicate key value")
|
||||||
|
}
|
||||||
242
internal/store/roles_integration_test.go
Normal file
242
internal/store/roles_integration_test.go
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/role"
|
||||||
|
"github.com/danyel/go-loose/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rolesFixture creates a tenant owned by owner plus a developer member. The
|
||||||
|
// developer is the interesting actor: it administers members but does not hold
|
||||||
|
// roles.manage, so it is the boundary the escalation guards are tested against.
|
||||||
|
func rolesFixture(t *testing.T) (s *store.Store, ctx context.Context, owner, developer, tenantID string) {
|
||||||
|
t.Helper()
|
||||||
|
s, _, ctx = integrationStore(t)
|
||||||
|
owner = newUser(t, s, ctx, "owner@example.test")
|
||||||
|
tenantID = newTenant(t, s, ctx, owner, "nmbs").ID
|
||||||
|
developer = addMember(t, s, ctx, owner, tenantID, "developer@example.test", "developer")
|
||||||
|
return s, ctx, owner, developer, tenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string {
|
||||||
|
t.Helper()
|
||||||
|
id, err := s.SystemRoleID(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up built-in role %q: %v", slug, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantCanDefineItsOwnRoleAndItIsEnforced(t *testing.T) {
|
||||||
|
s, ctx, owner, _, tenantID := rolesFixture(t)
|
||||||
|
application, err := s.CreateApplication(ctx, owner, store.Application{
|
||||||
|
TenantID: tenantID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create application: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.CreateRole(ctx, owner, tenantID, "release-manager",
|
||||||
|
"Release manager", "Prepares releases.",
|
||||||
|
[]string{string(role.ReadConsole), string(role.ManageContracts), string(role.ReadUsers)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create role: %v", err)
|
||||||
|
}
|
||||||
|
if created.System {
|
||||||
|
t.Error("a tenant role must not be marked as built in")
|
||||||
|
}
|
||||||
|
if created.TenantID == nil || *created.TenantID != tenantID {
|
||||||
|
t.Errorf("tenant id = %v, want %s", created.TenantID, tenantID)
|
||||||
|
}
|
||||||
|
if !contains(created.Permissions, string(role.ManageContracts)) {
|
||||||
|
t.Errorf("permissions = %v, want contracts.manage", created.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The role grants exactly what it says and nothing more.
|
||||||
|
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
|
||||||
|
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
|
||||||
|
t.Fatalf("assign the custom role: %v", err)
|
||||||
|
}
|
||||||
|
_, err = s.CreateAPIKey(ctx, member, application.ID, "key", "gl_prefix", secretHash("custom-denied"), nil)
|
||||||
|
assertPermission(t, "custom role issuing a key", err, false)
|
||||||
|
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
|
||||||
|
assertPermission(t, "custom role importing a contract", err, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomRoleIsScopedToItsOwnTenant(t *testing.T) {
|
||||||
|
s, ctx, owner, _, tenantID := rolesFixture(t)
|
||||||
|
other := newUser(t, s, ctx, "other-owner@example.test")
|
||||||
|
otherTenant := newTenant(t, s, ctx, other, "ypto").ID
|
||||||
|
|
||||||
|
created, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", []string{string(role.ReadConsole)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := s.ListRoles(ctx, other, otherTenant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list roles: %v", err)
|
||||||
|
}
|
||||||
|
for _, item := range roles {
|
||||||
|
if item.ID == created.ID {
|
||||||
|
t.Fatal("a role leaked across tenants")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.DeleteRole(ctx, other, created.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("cross-tenant delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdateRole(ctx, other, created.ID, "Hijacked", "", nil); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("cross-tenant update = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInRolesCannotBeEditedOrDeleted(t *testing.T) {
|
||||||
|
s, ctx, owner, _, _ := rolesFixture(t)
|
||||||
|
ownerRole := systemRole(t, s, ctx, "owner")
|
||||||
|
if _, err := s.UpdateRole(ctx, owner, ownerRole, "Supreme", "", nil); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("editing a built-in role = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteRole(ctx, owner, ownerRole); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("deleting a built-in role = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInUseCannotBeDeleted(t *testing.T) {
|
||||||
|
s, ctx, owner, _, tenantID := rolesFixture(t)
|
||||||
|
created, err := s.CreateRole(ctx, owner, tenantID, "support", "Support", "", []string{string(role.ReadConsole)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create role: %v", err)
|
||||||
|
}
|
||||||
|
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
|
||||||
|
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
|
||||||
|
t.Fatalf("assign role: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteRole(ctx, owner, created.ID); !errors.Is(err, store.ErrRoleInUse) {
|
||||||
|
t.Fatalf("delete an assigned role = %v, want ErrRoleInUse", err)
|
||||||
|
}
|
||||||
|
// Once nobody holds it the delete goes through.
|
||||||
|
if err := s.SetUserAccess(ctx, owner, tenantID, member, systemRole(t, s, ctx, "viewer"), nil); err != nil {
|
||||||
|
t.Fatalf("move the member off the role: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteRole(ctx, owner, created.ID); err != nil {
|
||||||
|
t.Fatalf("delete an unused role: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoleAssignmentCannotEscalate is the guard that matters most now that
|
||||||
|
// developer and operator may manage members: they must not be able to hand
|
||||||
|
// somebody a role more powerful than their own, which would otherwise be a
|
||||||
|
// straightforward route to owner.
|
||||||
|
func TestRoleAssignmentCannotEscalate(t *testing.T) {
|
||||||
|
s, ctx, owner, developer, tenantID := rolesFixture(t)
|
||||||
|
victim := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
|
||||||
|
ownerRole := systemRole(t, s, ctx, "owner")
|
||||||
|
|
||||||
|
if err := s.SetUserAccess(ctx, developer, tenantID, victim, ownerRole, nil); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("developer promoting a member to owner = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := s.InviteUser(ctx, developer, tenantID, "sneaky@example.test", "Sneaky", ownerRole, "hash"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("developer inviting as owner = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateRole(ctx, developer, tenantID, "shadow-owner", "Shadow", "",
|
||||||
|
[]string{string(role.ReadConsole), string(role.ManageTenants)}); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("developer creating an owner-grade role = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// Nor may they define any role at all: roles.manage is not theirs to hold,
|
||||||
|
// however weak the role they were trying to mint.
|
||||||
|
if _, err := s.CreateRole(ctx, developer, tenantID, "teammate", "Teammate", "",
|
||||||
|
[]string{string(role.ReadConsole), string(role.ReadUsers)}); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("developer creating any role = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// The developer can still maintain members using the roles already offered.
|
||||||
|
if _, err := s.InviteUser(ctx, developer, tenantID, "teammate@example.test", "Teammate",
|
||||||
|
systemRole(t, s, ctx, "viewer"), "hash"); err != nil {
|
||||||
|
t.Fatalf("developer inviting a member: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRolesManageIsRequiredToDefineRoles(t *testing.T) {
|
||||||
|
s, ctx, _, developer, tenantID := rolesFixture(t)
|
||||||
|
if _, err := s.ListRoles(ctx, developer, tenantID); err != nil {
|
||||||
|
t.Fatalf("a developer may read the role catalog: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateRole(ctx, developer, tenantID, "nope", "Nope", "",
|
||||||
|
[]string{string(role.ReadConsole)}); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("developer creating a role = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignableRolesExcludeAnythingAboveTheActor(t *testing.T) {
|
||||||
|
s, ctx, _, developer, tenantID := rolesFixture(t)
|
||||||
|
assignable, err := s.AssignableRoles(ctx, developer, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assignable roles: %v", err)
|
||||||
|
}
|
||||||
|
ownerRole := systemRole(t, s, ctx, "owner")
|
||||||
|
found := 0
|
||||||
|
for _, item := range assignable {
|
||||||
|
if !item.Assignable {
|
||||||
|
t.Errorf("role %q was returned but is not marked assignable", item.Slug)
|
||||||
|
}
|
||||||
|
if contains(item.Permissions, string(role.ManageTenants)) {
|
||||||
|
t.Errorf("developer may assign %q, which grants tenants.manage", item.Slug)
|
||||||
|
}
|
||||||
|
if item.ID == ownerRole {
|
||||||
|
t.Error("a developer must not be offered the owner role")
|
||||||
|
}
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
if found == 0 {
|
||||||
|
t.Fatal("a developer should still be offered the roles within their authority")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownPermissionIsRejectedWhenDefiningARole(t *testing.T) {
|
||||||
|
s, ctx, owner, _, tenantID := rolesFixture(t)
|
||||||
|
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
|
||||||
|
[]string{string(role.ReadConsole), "superpower"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a role must not be creatable with an unknown permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateRoleSlugIsRejected(t *testing.T) {
|
||||||
|
s, ctx, owner, _, tenantID := rolesFixture(t)
|
||||||
|
if _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "",
|
||||||
|
[]string{string(role.ReadConsole)}); err != nil {
|
||||||
|
t.Fatalf("create role: %v", err)
|
||||||
|
}
|
||||||
|
_, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Another", "",
|
||||||
|
[]string{string(role.ReadConsole)})
|
||||||
|
if !errors.Is(err, store.ErrRoleSlugTaken) {
|
||||||
|
t.Fatalf("duplicate slug = %v, want ErrRoleSlugTaken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTenantKeepsAnOwner guards the demotion path: the last owner of a tenant
|
||||||
|
// must not be able to walk away from it and lock every administrator out.
|
||||||
|
func TestTenantKeepsAnOwner(t *testing.T) {
|
||||||
|
s, ctx, owner, _, tenantID := rolesFixture(t)
|
||||||
|
admin := addMember(t, s, ctx, owner, tenantID, "admin@example.test", "admin")
|
||||||
|
adminRole := systemRole(t, s, ctx, "admin")
|
||||||
|
ownerRole := systemRole(t, s, ctx, "owner")
|
||||||
|
|
||||||
|
// The owner cannot simply step down while nobody else holds the role.
|
||||||
|
if err := s.SetUserAccess(ctx, owner, tenantID, owner, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
|
||||||
|
t.Fatalf("owner stepping down alone = %v, want ErrLastOwner", err)
|
||||||
|
}
|
||||||
|
// Handing the role over first makes the step down legal.
|
||||||
|
if err := s.SetUserAccess(ctx, owner, tenantID, admin, ownerRole, nil); err != nil {
|
||||||
|
t.Fatalf("promote the admin to owner: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetUserAccess(ctx, admin, tenantID, owner, adminRole, nil); err != nil {
|
||||||
|
t.Fatalf("step down once a second owner exists: %v", err)
|
||||||
|
}
|
||||||
|
// The admin is now the only owner and must not be able to walk away from it.
|
||||||
|
if err := s.SetUserAccess(ctx, admin, tenantID, admin, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
|
||||||
|
t.Fatalf("demoting the last owner = %v, want ErrLastOwner", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,8 +30,8 @@ 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
|
// Permissions is read from the role's own rows rather than from a Go matrix,
|
||||||
// capabilities instead of re-implementing the role matrix.
|
// so a tenant that defines its own roles is described accurately.
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,11 +130,13 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
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_id)
|
||||||
SELECT $1, $2, $3
|
SELECT $1, $2, r.id
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
|
FROM roles r
|
||||||
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
|
WHERE r.slug = $3 AND r.tenant_id IS NULL
|
||||||
ON CONFLICT DO NOTHING`, tenantID, userID, role.Owner); err != nil {
|
AND (NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
|
||||||
|
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2))
|
||||||
|
ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, `
|
if _, err = tx.ExecContext(ctx, `
|
||||||
|
|
@ -157,9 +159,15 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
|
||||||
|
|
||||||
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
|
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT t.id, t.slug, t.name, m.role
|
SELECT t.id, t.slug, t.name, r.slug,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(rp.permission ORDER BY rp.permission)
|
||||||
|
FROM role_permissions rp
|
||||||
|
WHERE rp.role_id = m.role_id
|
||||||
|
), '[]'::json)
|
||||||
FROM tenants t
|
FROM tenants t
|
||||||
JOIN memberships m ON m.tenant_id = t.id
|
JOIN memberships m ON m.tenant_id = t.id
|
||||||
|
JOIN roles r ON r.id = m.role_id
|
||||||
WHERE m.user_id = $1
|
WHERE m.user_id = $1
|
||||||
ORDER BY t.name`, userID)
|
ORDER BY t.name`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -169,10 +177,13 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error
|
||||||
result := make([]Tenant, 0)
|
result := make([]Tenant, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item Tenant
|
var item Tenant
|
||||||
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
|
var permissions []byte
|
||||||
|
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role, &permissions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permissions, &item.Permissions); 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()
|
||||||
|
|
@ -190,11 +201,26 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Tenant{}, err
|
return Tenant{}, err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
|
// Whoever creates a tenant owns it.
|
||||||
|
var permissions []byte
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO memberships(tenant_id, user_id, role_id)
|
||||||
|
SELECT $1, $2, r.id FROM roles r
|
||||||
|
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
|
||||||
|
RETURNING (SELECT slug FROM roles WHERE slug = 'owner' AND tenant_id IS NULL),
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(rp.permission ORDER BY rp.permission)
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN roles granted ON granted.id = rp.role_id
|
||||||
|
WHERE granted.slug = 'owner' AND granted.tenant_id IS NULL
|
||||||
|
), '[]'::json)`,
|
||||||
|
tenant.ID, userID).Scan(&tenant.Role, &permissions)
|
||||||
|
if err != nil {
|
||||||
|
return Tenant{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permissions, &tenant.Permissions); err != nil {
|
||||||
return Tenant{}, err
|
return Tenant{}, err
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -237,14 +263,11 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic
|
||||||
row := s.db.QueryRowContext(ctx, `
|
row := s.db.QueryRowContext(ctx, `
|
||||||
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 `+permissionPredicate("$1", "$6", "$7")+`
|
||||||
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))
|
string(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) {
|
||||||
|
|
@ -287,10 +310,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
|
||||||
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
|
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
|
||||||
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
|
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$6", "$7")+`
|
||||||
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, role.Grants(role.ManageKeys)).
|
applicationID, name, prefix, hash, expiresAt, userID, string(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
|
||||||
|
|
@ -301,9 +323,8 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
|
||||||
func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
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
|
WHERE k.id = $1 AND `+permissionPredicate("k.tenant_id", "$2", "$3"),
|
||||||
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`,
|
keyID, userID, string(role.ManageKeys))
|
||||||
keyID, userID, role.Grants(role.ManageKeys))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -363,11 +384,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
|
||||||
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
|
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
|
||||||
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
|
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$5", "$6")+`
|
||||||
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)).
|
string(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
|
||||||
|
|
|
||||||
65
web/app.js
65
web/app.js
|
|
@ -1,15 +1,12 @@
|
||||||
const state = {
|
const state = {
|
||||||
tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [],
|
tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [],
|
||||||
roles: [], profile: null, selectedTenant: ''
|
roles: [], permissions: [], profile: null, selectedTenant: ''
|
||||||
};
|
};
|
||||||
// Capability names mirror internal/role. The server sends the grants for the
|
// Capability names mirror internal/role. The server sends the grants for the
|
||||||
// selected tenant so the console never re-implements the matrix.
|
// selected tenant so the console never re-implements the matrix.
|
||||||
const grants = (tenant, permission) => Boolean(tenant?.permissions?.includes(permission));
|
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 => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|
@ -48,11 +45,28 @@ 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 || '';
|
||||||
|
await loadRoles();
|
||||||
renderIdentity();
|
renderIdentity();
|
||||||
renderRoleOptions();
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadRoles fetches the role catalog for the selected tenant. Roles are rows now
|
||||||
|
// rather than a fixed list, so they have to be asked for per tenant, and the
|
||||||
|
// server marks which ones the signed-in user is allowed to hand out.
|
||||||
|
async function loadRoles() {
|
||||||
|
if (!state.selectedTenant) {
|
||||||
|
state.roles = [];
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles
|
||||||
|
} catch (error) {
|
||||||
|
// A user who cannot read the role catalog still gets the rest of the
|
||||||
|
// console; the invite and access dialogs simply offer nothing.
|
||||||
|
state.roles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderIdentity() {
|
function renderIdentity() {
|
||||||
const profile = state.profile || {};
|
const profile = state.profile || {};
|
||||||
$('#identity-name').textContent = profile.display_name || profile.email || '';
|
$('#identity-name').textContent = profile.display_name || profile.email || '';
|
||||||
|
|
@ -66,11 +80,15 @@ function renderIdentity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoleOptions() {
|
// assignableRoles is what the invite and access dialogs may offer: the server
|
||||||
// Inviting an owner is rejected by the server, so leave it out of the list.
|
// refuses to hand out a role more powerful than the caller's own, so offering
|
||||||
const invitable = state.roles.filter(item => item.value !== 'owner');
|
// anything else would only produce an error.
|
||||||
$('#invite-role').innerHTML = roleOptions(invitable);
|
function assignableRoles() {
|
||||||
$('#access-role').innerHTML = roleOptions(state.roles);
|
return state.roles.filter(item => item.assignable)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(roleID) {
|
||||||
|
return state.roles.find(item => item.id === roleID)?.name || roleID
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
|
@ -98,7 +116,8 @@ function render() {
|
||||||
$('#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');
|
$('#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>${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');
|
$('#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(roleLabel(item.role_id) || 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');
|
||||||
|
renderRoleOptions();
|
||||||
$$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
|
$$('[data-tenant-application-action]').forEach(element => element.hidden = !canManageApplications);
|
||||||
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
|
$$('[data-tenant-key-action]').forEach(element => element.hidden = !canManageKeys);
|
||||||
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
|
$$('[data-tenant-contract-action]').forEach(element => element.hidden = !canManageContracts);
|
||||||
|
|
@ -110,6 +129,20 @@ function render() {
|
||||||
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''));
|
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderRoleOptions fills both role pickers. The current value is preserved so
|
||||||
|
// that re-rendering the table does not silently reset a pending edit.
|
||||||
|
function renderRoleOptions() {
|
||||||
|
const options = assignableRoles();
|
||||||
|
const html = options.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
||||||
|
for (const id of ['#invite-role', '#access-role']) {
|
||||||
|
const select = $(id);
|
||||||
|
if (!select) continue;
|
||||||
|
const current = select.value;
|
||||||
|
select.innerHTML = html;
|
||||||
|
if (current && options.some(item => item.id === current)) select.value = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 initials = name => String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?';
|
||||||
const avatarCell = item => item.avatar_url
|
const avatarCell = item => item.avatar_url
|
||||||
|
|
@ -230,7 +263,10 @@ document.addEventListener('click', async event => {
|
||||||
form.tenant_id.value = user.tenant_id;
|
form.tenant_id.value = user.tenant_id;
|
||||||
form.approval_mode.value = 'false';
|
form.approval_mode.value = 'false';
|
||||||
$('#approval-tenant-label').hidden = true;
|
$('#approval-tenant-label').hidden = true;
|
||||||
form.role.value = user.role;
|
// A role the caller may not hand out is not in the picker, so fall back
|
||||||
|
// to the first one they can assign rather than submitting the old value.
|
||||||
|
const current = assignableRoles().some(item => item.id === user.role_id);
|
||||||
|
form.role_id.value = current ? user.role_id : assignableRoles()[0]?.id || '';
|
||||||
renderAccessApps(user.tenant_id, user.application_ids);
|
renderAccessApps(user.tenant_id, user.application_ids);
|
||||||
$('#access-dialog').showModal();
|
$('#access-dialog').showModal();
|
||||||
return
|
return
|
||||||
|
|
@ -242,7 +278,7 @@ document.addEventListener('click', async event => {
|
||||||
form.approval_mode.value = 'true';
|
form.approval_mode.value = 'true';
|
||||||
$('#approval-tenant-label').hidden = false;
|
$('#approval-tenant-label').hidden = false;
|
||||||
form.approval_tenant_id.value = state.selectedTenant;
|
form.approval_tenant_id.value = state.selectedTenant;
|
||||||
form.role.value = 'viewer';
|
form.role_id.value = assignableRoles()[0]?.id || '';
|
||||||
renderAccessApps(state.selectedTenant, []);
|
renderAccessApps(state.selectedTenant, []);
|
||||||
$('#access-dialog').showModal()
|
$('#access-dialog').showModal()
|
||||||
}
|
}
|
||||||
|
|
@ -269,8 +305,9 @@ mode.addEventListener('click', () => {
|
||||||
document.documentElement.dataset.mode = next;
|
document.documentElement.dataset.mode = next;
|
||||||
localStorage.setItem('gl-mode', next)
|
localStorage.setItem('gl-mode', next)
|
||||||
});
|
});
|
||||||
$('#tenant-context').addEventListener('change', event => {
|
$('#tenant-context').addEventListener('change', async event => {
|
||||||
state.selectedTenant = event.target.value;
|
state.selectedTenant = event.target.value;
|
||||||
|
await loadRoles();
|
||||||
render()
|
render()
|
||||||
});
|
});
|
||||||
$('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, []));
|
$('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, []));
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
|
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/docs">API docs</a>
|
<a href="/docs">API docs</a>
|
||||||
|
<a href="/roles">Roles</a>
|
||||||
<label class="compact">Tenant<select id="tenant-context"></select></label>
|
<label class="compact">Tenant<select id="tenant-context"></select></label>
|
||||||
<label class="compact">Theme
|
<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>
|
<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>
|
||||||
|
|
@ -76,7 +77,7 @@
|
||||||
<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-user-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>
|
<p class="muted">Roles are per tenant and grant capabilities. <a href="/roles">Manage roles</a> to review or define your own. <a href="/profile">Open your own profile</a> to change your name and picture.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -87,8 +88,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" id="invite-role" required></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" id="invite-role" required></select></label><p class="muted">You can only assign roles that are no more powerful than your own.</p><button type="submit">Invite user</button></form></dialog>
|
||||||
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role" id="access-role" required></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
|
<dialog id="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" 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>
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,9 @@
|
||||||
"tenant_id": {"type": "string", "format": "uuid"},
|
"tenant_id": {"type": "string", "format": "uuid"},
|
||||||
"tenant_slug": {"type": "string"},
|
"tenant_slug": {"type": "string"},
|
||||||
"tenant_name": {"type": "string"},
|
"tenant_name": {"type": "string"},
|
||||||
"role": {"type": "string", "enum": ["owner", "admin", "developer", "operator", "viewer", "user"]},
|
"role": {"type": "string", "description": "Slug of the role, which may be a role this tenant defined."},
|
||||||
"permissions": {"type": "array", "items": {"type": "string"}}
|
"role_id": {"type": "string", "format": "uuid", "description": "Identifier of the role row. Send this back when assigning the role."},
|
||||||
|
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Profile": {
|
"Profile": {
|
||||||
|
|
@ -75,12 +76,45 @@
|
||||||
"remove_avatar": {"type": "boolean"}
|
"remove_avatar": {"type": "boolean"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"RoleCatalogEntry": {
|
"Role": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"description": "A named bundle of permissions. A built-in role has no tenant_id, is visible to every tenant, and cannot be edited or deleted. A tenant may define its own.",
|
||||||
|
"required": ["id", "slug", "name", "system", "permissions"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"value": {"type": "string"},
|
"id": {"type": "string", "format": "uuid"},
|
||||||
|
"tenant_id": {"type": "string", "format": "uuid", "description": "Absent for built-in roles."},
|
||||||
|
"slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"},
|
||||||
|
"name": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||||
|
"description": {"type": "string", "maxLength": 500},
|
||||||
|
"system": {"type": "boolean", "description": "True for built-in roles, which are read only."},
|
||||||
|
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}},
|
||||||
|
"member_count": {"type": "integer", "description": "Members currently holding the role. A role that still has members cannot be deleted."},
|
||||||
|
"assignable": {"type": "boolean", "description": "Whether the caller may assign this role. False when the role grants a permission the caller does not hold, which is what stops role assignment becoming a way to gain authority."}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PermissionValue": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The closed vocabulary of capabilities a role may grant. Mirrors internal/role and the role_permissions check constraint.",
|
||||||
|
"enum": ["tenants.manage", "roles.manage", "users.manage", "applications.manage", "keys.manage", "contracts.manage", "users.read", "console.read", "profile.edit"]
|
||||||
|
},
|
||||||
|
"PermissionEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A permission with the label and description the console renders.",
|
||||||
|
"properties": {
|
||||||
|
"value": {"$ref": "#/components/schemas/PermissionValue"},
|
||||||
"label": {"type": "string"},
|
"label": {"type": "string"},
|
||||||
"permissions": {"type": "array", "items": {"type": "string"}}
|
"description": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RoleRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "permissions"],
|
||||||
|
"properties": {
|
||||||
|
"tenant_id": {"type": "string", "format": "uuid", "description": "Required when creating. Ignored when updating, which takes the role's own tenant."},
|
||||||
|
"slug": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$", "description": "Required when creating, and fixed for the life of the role."},
|
||||||
|
"name": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||||
|
"description": {"type": "string", "maxLength": 500},
|
||||||
|
"permissions": {"type": "array", "items": {"$ref": "#/components/schemas/PermissionValue"}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +334,8 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"User Management"
|
"User Management"
|
||||||
],
|
],
|
||||||
"summary": "Invite a tenant user with an admin, developer, operator, viewer, or user role",
|
"summary": "Invite a tenant user with a role",
|
||||||
|
"description": "Requires users.manage in the tenant, which owner, admin, developer, and operator hold. role_id must name a role of that tenant whose permissions are a subset of the caller's own.",
|
||||||
"security": [{"session": []}],
|
"security": [{"session": []}],
|
||||||
"responses": {"201": {"description": "User invited"}}
|
"responses": {"201": {"description": "User invited"}}
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +346,7 @@
|
||||||
"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.",
|
"description": "Requires users.manage. The caller may not grant a role more powerful than their own, and may not demote the last owner of a tenant.",
|
||||||
"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"}}
|
||||||
|
|
@ -319,17 +354,53 @@
|
||||||
},
|
},
|
||||||
"/api/v1/roles": {
|
"/api/v1/roles": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["User Management"],
|
"tags": ["Role Management"],
|
||||||
"summary": "List tenant roles with their capabilities",
|
"summary": "List the roles available in a tenant",
|
||||||
|
"description": "Returns every built-in role plus the roles this tenant defined, each annotated with whether the caller may assign it.",
|
||||||
"security": [{"session": []}],
|
"security": [{"session": []}],
|
||||||
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/RoleCatalogEntry"}}}}}}
|
"parameters": [{"in": "query", "name": "tenant_id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||||
|
"responses": {"200": {"description": "Role catalog", "content": {"application/json": {"schema": {"type": "object", "properties": {"roles": {"type": "array", "items": {"$ref": "#/components/schemas/Role"}}}}}}}, "404": {"description": "Not a member of that tenant"}}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": ["Role Management"],
|
||||||
|
"summary": "Define a role for a tenant",
|
||||||
|
"description": "Requires roles.manage in the tenant. The role may not grant a permission the caller does not already hold, so an administrator cannot mint their way above their own authority.",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleRequest"}}}},
|
||||||
|
"responses": {"201": {"description": "Role created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "400": {"description": "Malformed slug or name, or an unknown permission"}, "404": {"description": "The caller does not hold roles.manage, or the role would exceed their authority"}, "409": {"description": "The slug is already used in this tenant"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/roles/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": ["Role Management"],
|
||||||
|
"summary": "Rename a role and replace its permissions",
|
||||||
|
"description": "Built-in roles are immutable. Saving replaces the permissions of every member holding the role, so treat it as a live change.",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||||
|
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RoleRequest"}}}},
|
||||||
|
"responses": {"200": {"description": "Role updated", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Role"}}}}, "400": {"description": "Malformed name or an unknown permission"}, "404": {"description": "Unknown role, a built-in role, or a role belonging to another tenant"}}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": ["Role Management"],
|
||||||
|
"summary": "Delete one of the tenant's own roles",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
|
||||||
|
"responses": {"204": {"description": "Role deleted"}, "404": {"description": "Unknown role, a built-in role, or a role belonging to another tenant"}, "409": {"description": "The role still has members"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/roles": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Role Management"],
|
||||||
|
"summary": "Role management screen",
|
||||||
|
"security": [{"session": []}],
|
||||||
|
"responses": {"200": {"description": "HTML page"}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/users/{id}/approve": {
|
"/api/v1/users/{id}/approve": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": ["User Management"],
|
"tags": ["User Management"],
|
||||||
"summary": "Approve a waiting-room user into a tenant",
|
"summary": "Approve a waiting-room user into a tenant",
|
||||||
"description": "Requires a system administrator session.",
|
"description": "Requires a system administrator session. Unlike the tenant-scoped endpoints this is not limited by the caller's own role, because it grants a first membership across tenants.",
|
||||||
"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": "User approved"}, "404": {"description": "User or permission not found"}}
|
"responses": {"204": {"description": "User approved"}, "404": {"description": "User or permission not found"}}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Console</a>
|
<a href="/">Console</a>
|
||||||
<a href="/docs">API docs</a>
|
<a href="/docs">API docs</a>
|
||||||
|
<a href="/roles">Roles</a>
|
||||||
<label class="compact">Theme
|
<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>
|
<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>
|
||||||
|
|
|
||||||
90
web/roles.html
Normal file
90
web/roles.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<!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 · Roles</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<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">Tenant<select id="tenant-context"></select></label>
|
||||||
|
<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>
|
||||||
|
<a id="identity" class="identity" href="/profile"><img id="identity-avatar" alt="" width="22" height="22" hidden><span id="identity-name"></span></a>
|
||||||
|
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">ROLE MANAGEMENT</p>
|
||||||
|
<h1>Who may do<br><span>what, per tenant.</span></h1>
|
||||||
|
<p class="lede">A role is a named bundle of permissions. Built-in roles are shared by every tenant and cannot be edited; your own tenant can define as many more as it needs.</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="pulse"></div>
|
||||||
|
<div><strong id="role-summary">Roles</strong><span id="role-tenant">—</span></div>
|
||||||
|
<code id="role-count">—</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="notice" class="notice" hidden></div>
|
||||||
|
|
||||||
|
<section class="stats" id="stats">
|
||||||
|
<article><span>Roles</span><strong id="stat-roles">—</strong></article>
|
||||||
|
<article><span>Built-in</span><strong id="stat-system">—</strong></article>
|
||||||
|
<article><span>Custom</span><strong id="stat-custom">—</strong></article>
|
||||||
|
<article><span>Assignable to you</span><strong id="stat-assignable">—</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel active" id="roles">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><p class="eyebrow">PERMISSION CATALOG</p><h2>Roles</h2></div>
|
||||||
|
<button id="new-role">New role</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Role</th><th>Slug</th><th>Permissions</th><th>Members</th><th>Assignable to you</th><th></th></tr></thead>
|
||||||
|
<tbody id="role-list"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="muted">You can only create and assign roles that grant no more than you already hold, so managing members can never be used to gain authority.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel active" id="permission-reference">
|
||||||
|
<div class="panel-head"><div><p class="eyebrow">VOCABULARY</p><h2>Permissions</h2></div></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Permission</th><th>What it allows</th></tr></thead>
|
||||||
|
<tbody id="permission-list"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="role-dialog">
|
||||||
|
<form id="role-form">
|
||||||
|
<div class="dialog-head"><h3 id="role-dialog-title">New role</h3><button type="button" class="close">×</button></div>
|
||||||
|
<input type="hidden" name="role_id">
|
||||||
|
<label id="role-slug-label">Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="release-manager"></label>
|
||||||
|
<label>Name<input name="name" required maxlength="100" placeholder="Release manager"></label>
|
||||||
|
<label>Description <small>(optional)</small><textarea name="description" maxlength="500" placeholder="Prepares releases."></textarea></label>
|
||||||
|
<fieldset><legend>Permissions</legend><div id="role-permissions" class="check-list"></div></fieldset>
|
||||||
|
<p class="muted" id="role-hint"></p>
|
||||||
|
<button type="submit">Save role</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/assets/roles.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
197
web/roles.js
Normal file
197
web/roles.js
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
const state = {tenants: [], roles: [], permissions: [], selectedTenant: ''};
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||||
|
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[c]));
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const dashboard = await api('/api/v1/dashboard');
|
||||||
|
state.tenants = dashboard.tenants;
|
||||||
|
state.permissions = dashboard.permissions || [];
|
||||||
|
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
|
||||||
|
renderIdentity(dashboard.profile);
|
||||||
|
await loadRoles();
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIdentity(profile) {
|
||||||
|
profile = 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
if (!state.selectedTenant) {
|
||||||
|
state.roles = [];
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.roles = (await api(`/api/v1/roles?tenant_id=${encodeURIComponent(state.selectedTenant)}`)).roles
|
||||||
|
} catch (error) {
|
||||||
|
state.roles = [];
|
||||||
|
notice('You do not have permission to read the roles of this tenant.', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTenant() {
|
||||||
|
return state.tenants.find(tenant => tenant.id === state.selectedTenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManageRoles = () => Boolean(selectedTenant()?.permissions?.includes('roles.manage'));
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const tenant = selectedTenant();
|
||||||
|
const custom = state.roles.filter(item => !item.system);
|
||||||
|
$('#tenant-context').innerHTML = state.tenants.map(item =>
|
||||||
|
`<option value="${escapeHTML(item.id)}" ${item.id === state.selectedTenant ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
|
||||||
|
$('#role-tenant').textContent = tenant ? tenant.name : 'No tenant';
|
||||||
|
$('#role-count').textContent = String(state.roles.length);
|
||||||
|
$('#role-summary').textContent = canManageRoles() ? 'Roles · editable' : 'Roles · read only';
|
||||||
|
$('#stat-roles').textContent = state.roles.length;
|
||||||
|
$('#stat-system').textContent = state.roles.length - custom.length;
|
||||||
|
$('#stat-custom').textContent = custom.length;
|
||||||
|
$('#stat-assignable').textContent = state.roles.filter(item => item.assignable).length;
|
||||||
|
$('#new-role').hidden = !canManageRoles();
|
||||||
|
|
||||||
|
$('#role-list').innerHTML = state.roles.map(item => `<tr>
|
||||||
|
<td><strong>${escapeHTML(item.name)}</strong>${item.system ? ' <span class="badge">built-in</span>' : ''}<br><small>${escapeHTML(item.description || 'No description')}</small></td>
|
||||||
|
<td><code>${escapeHTML(item.slug)}</code></td>
|
||||||
|
<td class="capabilities">${item.permissions.map(permission => `<span class="chip">${escapeHTML(permission)}</span>`).join('') || '<span class="muted">None</span>'}</td>
|
||||||
|
<td>${item.member_count}</td>
|
||||||
|
<td>${item.assignable ? 'Yes' : '<span class="muted">No</span>'}</td>
|
||||||
|
<td>${item.system ? '' : canManageRoles() ? `<button class="card-action" data-edit="${escapeHTML(item.id)}">Edit</button><button class="danger" data-delete="${escapeHTML(item.id)}">Delete</button>` : ''}</td>
|
||||||
|
</tr>`).join('') || '<tr><td colspan="6">No roles available for this tenant.</td></tr>';
|
||||||
|
|
||||||
|
$('#permission-list').innerHTML = state.permissions.map(item => `<tr>
|
||||||
|
<td><code>${escapeHTML(item.value)}</code></td>
|
||||||
|
<td>${escapeHTML(item.description)}</td>
|
||||||
|
</tr>`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionCheckboxes(selected) {
|
||||||
|
const held = new Set(selected || []);
|
||||||
|
$('#role-permissions').innerHTML = state.permissions.map(item => `<label>
|
||||||
|
<input type="checkbox" name="permissions" value="${escapeHTML(item.value)}" ${held.has(item.value) ? 'checked' : ''}>
|
||||||
|
<span><strong>${escapeHTML(item.label)}</strong><br><small>${escapeHTML(item.description)}</small></span>
|
||||||
|
</label>`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(item) {
|
||||||
|
const form = $('#role-form');
|
||||||
|
form.reset();
|
||||||
|
form.role_id.value = item?.id || '';
|
||||||
|
form.slug.value = item?.slug || '';
|
||||||
|
form.name.value = item?.name || '';
|
||||||
|
form.description.value = item?.description || '';
|
||||||
|
// The slug identifies a role for the lifetime of the role, so it is fixed
|
||||||
|
// once the role exists.
|
||||||
|
$('#role-slug-label').hidden = Boolean(item);
|
||||||
|
form.slug.required = !item;
|
||||||
|
$('#role-dialog-title').textContent = item ? `Edit ${item.name}` : 'New role';
|
||||||
|
$('#role-hint').textContent = item
|
||||||
|
? 'Saving replaces the permissions of every member holding this role.'
|
||||||
|
: 'The slug cannot be changed later.';
|
||||||
|
permissionCheckboxes(item?.permissions);
|
||||||
|
$('#role-dialog').showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#new-role').addEventListener('click', () => openDialog(null));
|
||||||
|
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
|
||||||
|
|
||||||
|
$('#role-form').addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.target;
|
||||||
|
const permissions = [...form.querySelectorAll('input[name=permissions]:checked')].map(input => input.value);
|
||||||
|
const id = form.role_id.value;
|
||||||
|
const body = id
|
||||||
|
? {name: form.name.value, description: form.description.value, permissions}
|
||||||
|
: {tenant_id: state.selectedTenant, slug: form.slug.value, name: form.name.value, description: form.description.value, permissions};
|
||||||
|
try {
|
||||||
|
await api(id ? `/api/v1/roles/${id}` : '/api/v1/roles', {
|
||||||
|
method: id ? 'PUT' : 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
form.closest('dialog').close();
|
||||||
|
notice(id ? 'Role updated' : 'Role created');
|
||||||
|
await loadRoles();
|
||||||
|
render()
|
||||||
|
} catch (error) {
|
||||||
|
notice(error.message, true)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', async event => {
|
||||||
|
const edit = event.target.dataset.edit;
|
||||||
|
if (edit) {
|
||||||
|
openDialog(state.roles.find(item => item.id === edit));
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const remove = event.target.dataset.delete;
|
||||||
|
if (remove) {
|
||||||
|
const item = state.roles.find(role => role.id === remove);
|
||||||
|
if (!confirm(`Delete the role "${item.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/v1/roles/${remove}`, {method: 'DELETE'});
|
||||||
|
notice('Role deleted');
|
||||||
|
await loadRoles();
|
||||||
|
render()
|
||||||
|
} catch (error) {
|
||||||
|
notice(error.message, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
$('#tenant-context').addEventListener('change', async event => {
|
||||||
|
state.selectedTenant = event.target.value;
|
||||||
|
await loadRoles();
|
||||||
|
render()
|
||||||
|
});
|
||||||
|
|
||||||
|
load().catch(error => notice(error.message, true));
|
||||||
Loading…
Add table
Add a link
Reference in a new issue