Add OIDC sign-in, group-derived roles and declarative instance configuration

Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-07 13:28:43 +01:00
1 parent e37d583179
commit 4764f3545f
68 files changed
+7220 -964

No files matched your search

+18 -8
View File
@@ -6,13 +6,13 @@ A modular Rust application serves the web/API and runs a durable publication wor
## Persistence and concurrency
Users, sessions, workspaces, memberships, audit, history, provider checkpoints, and sync jobs are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types.
Users, sessions, external identities, workspaces, memberships, audit, history, provider checkpoints, sync jobs, OIDC flows, instance state and instance audit are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types.
Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization, then writes membership, history, audit, and the durable sync request atomically. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state.
Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization (a workspace grant, or instance administrator), then writes employee-role grants when they changed, history, audit, and the durable sync request atomically. `Store::system_commit` is the same statement without a grant check for configuration sync and identity linking; it never takes request input directly. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state.
The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain queued when older runs succeed or fail. Jobs retry with bounded backoff; fifteen-minute reconciliation extends the horizon and detects drift. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout.
Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. A JSM destination can have only one local workspace binding. Never edit applied migrations.
Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The process that owns migrations (the `migrate` role, or a single `all` process without the skip flag) also synchronizes the instance configuration; replicas only read it. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. Migration 9 makes passwords optional and adds user email, groups, instance-administrator and bootstrap flags, workspace key and disabled timestamp, a unique `(issuer, subject)` index, `instance_state`, `instance_audit` and `oidc_flows`. A JSM destination can have only one local workspace binding. Never edit applied migrations.
## Scheduling model
@@ -28,7 +28,17 @@ The engine receives explicit state and time bounds. It must produce the same res
## Identity and permissions
Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership. External identity linking is a future adapter, never a replacement for internal user IDs.
Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS; a session lasts 12 hours. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership; instance administrators pass the permission check through the `users.instance_admin` flag, in `Store::permissions`/`owner` and inside the commit statement itself. Disabled and demo-expired workspaces are not found by `Store::workspace`. External identities bind `(issuer, subject)` to an internal user ID and never replace it.
### Instance access modules
- `access` is pure: configuration types and strict validation, `resolve_access(config, groups, bootstrap)` (exact group match, administrator beats employee), claim extraction with literal and dotted paths, and bootstrap password derivation (HMAC-SHA256 over `meguri-bootstrap:{username}:{UTC YYYYMMDDHH}` with a database-held secret, first 20 bytes base32). The loaded configuration is installed process-wide for request handlers; service functions take it as an argument.
- `identity` owns every write that turns configuration or provider data into state: `sync_config`, `ensure_workspace`, `apply_access`, bootstrap accounts, `sso_sign_in` and `link_contacts`. Cot has no transactions, so each step is an idempotent statement and concurrent replicas converge; `preflight` rejects configuration-only problems (listing no workspaces while enabled ones exist, ambiguous adoption by name, a bootstrap name owned by an SSO account) before the first write. Sync order: workspaces (create/adopt/enable/rename), disable unlisted workspaces (their sync job and memberships are removed in the same statement), bootstrap accounts, then `reconcile_all`.
- `apply_access` is the single place that turns groups into access. One CTE stores the user's groups and instance-administrator flag and rewrites their `workspace_members` rows: rows outside the desired set are deleted, administrators get every permission, employees get the workspace document's `member_permissions` read in the same statement. SSO sign-in, the administrator's password-user editor and startup reconciliation all end here. Membership rows are a materialized view of groups; nothing grants a row any other way.
- `oidc` implements discovery (cached ten minutes, issuer must match), JWKS (cached, refreshed at most every 15 seconds for an unknown `kid`), PKCE, the one-time `oidc_flows` record, code exchange with client-secret basic authentication, and RS256 token verification (signature, issuer, audience, expiry, nonce, authorized party). It returns a trusted profile; `identity::sso_sign_in` decides what the profile may do.
- `admin` implements the instance administrator's operations (overview, password users, group edits, password reset, workspace deletion) and writes `instance_audit`. Every function re-checks `instance_admin`; the web layer only routes.
The employee permission set lives in `WorkspaceDocument.member_permissions` (default `view`, `edit_schedules`, `request_overrides`). Changing it is an ordinary revision-checked commit that also updates every employee membership row, so the set applies at once. Because a commit's authorization is evaluated in SQL against `workspace_members`, those rows hold the effective permissions.
Replacement preview is a read-only service using the same engine, revision, participant and permission checks as submission. Calendar actions use server-generated interval boundaries; inclusive range inputs are converted through schedule handovers on the server. Volunteering changes the initiator, not the approval requirements. Comments remain in the backward-compatible reason/force_reason fields but are optional action annotations. Current-shift clipping uses one explicit server timestamp per validation/application.
@@ -52,9 +62,9 @@ Random recipients and escalation repeats require JSM Premium/Enterprise. Provide
JSM documents `repeat.waitInterval=0` as disabling repeats even when count is nonzero; the reference Terraform uses this combination. Enabling repeats requires a positive interval. A disabled local count maps to provider count 1 / interval 0 because the provider count minimum is 1. The reference's immediate step uses delay 0; verify it in live acceptance because the published schema lists a minimum of 1. Never silently change immediate notification to a one-minute delay.
## Scheduling metadata and import boundaries
WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented or linked through unverified email. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots.
WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented. At SSO sign-in `identity::link_contacts` merges a contact whose email equals the signed-in user's into that user (the contact ID is rewritten across the current document by exact string replacement, kept as `Person.aliases`, and resolved by audit and the UI for historical snapshots) through `system_commit` with `person.linked` audit, only in workspaces where the user is already a member; linking never creates access. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots.
Workspace creation atomically creates its document, owner membership, initial history, audit and sync request. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration.
Workspaces are created only by configuration sync, which atomically creates the document, initial history, audit and sync request with its configured `key` and no members; memberships appear when users are reconciled. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration.
Migration 3 upgrades old JSON snapshots without changing old handover boundaries or global-holiday meaning and introduces workspace working intervals. Migration 4 adds user country (CY) and an optional display timezone (null follows the browser). Current scheduling state copies the workspace timezone; historical documents retain the timezone effective at their revision. Country-profile changes propagate as dated workspace revisions, while display-timezone preferences remain outside the engine.
@@ -64,7 +74,7 @@ Email-only recipients resolve through [Jira user search](https://developer.atlas
`demo_runtime` chooses the database before Cot migrations. A separate `public.meguri_runtime_guard` records last startup mode; proof and reset recheck it under a PostgreSQL advisory transaction lock. Empty-DB claiming checks user relations. Reset enumerates only Meguri application tables, uses no CASCADE, and preserves migration/provenance metadata. Distinct application names detect another active client. A protected configured DB triggers a separate database and an immutable process notice surfaced by `/api/session`; no credentials are exposed. Normal startup marks its database normal and never schedules cleanup.
Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer refreshes expired seed workspaces through the ordinary services; only the five baseline account identities are permanent during a run. External provider writes are disabled in demo at both configuration and dispatch boundaries.
Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer runs the ordinary configuration sync against the built-in demo configuration (`src/demo-config.yaml`, the production format), recreates expired seed workspaces and reseeds them through the ordinary services; the six seeded accounts (`admin` bootstrap administrator, `alice`, `bob`, `carol`, `dave`, `erin`) are pinned and created through the same administrator operations as in production, with groups. They share one random password per process start, kept in the process-wide demo notice and published by `/api/session`; accounts created later by an administrator expire. External provider writes are disabled in demo at both configuration and dispatch boundaries.
## Presentation catalog
@@ -101,7 +111,7 @@ Generation rule results use the actual authorized server expansion, including sc
## Workspace aggregate
`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores per-user grants and ownership; there is no parent entity. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams.
`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores a user's effective permissions and administrator flag, derived from groups; there is no parent entity. `key` links the row to the configuration and `disabled_at` hides it. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams.
Variable list summaries render nested nodes by type. Object entries prefer a nonempty text `name` or `title`; otherwise they show a bounded key/count summary. People resolve through the scoped directory. Summaries never read a container’s null scalar slot as its content.