From 4764f3545f0cc32ee32e01e7b4511d84e9fb2fd1 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Wed, 7 Oct 2026 13:28:43 +0100 Subject: [PATCH] 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 --- .env.example | 11 +- .github/workflows/check.yml | 4 +- AGENTS.md | 7 +- Cargo.lock | 469 +++++++++- Cargo.toml | 4 + README.md | 14 +- deploy/gke.yaml | 76 +- deploy/instance-config.example.yaml | 63 ++ deploy/migrate.yaml | 8 + docs/architecture.md | 26 +- docs/deployment.md | 57 +- docs/development.md | 26 +- docs/product.md | 30 +- docs/testing.md | 18 +- package.json | 2 +- scripts/dev.ps1 | 4 +- src/access.rs | 535 +++++++++++ src/admin.rs | 315 +++++++ src/audit.rs | 21 + src/demo-config.yaml | 24 + src/demo.rs | 311 ++++--- src/demo_runtime.rs | 11 +- src/domain.rs | 22 + src/engine.rs | 1 + src/generation.rs | 1 + src/generation_service.rs | 1 + src/identity.rs | 975 +++++++++++++++++++++ src/lib.rs | 4 + src/main.rs | 66 +- src/migrations.rs | 2 + src/migrations/m_0009_access.rs | 27 + src/models.rs | 40 +- src/oidc.rs | 348 ++++++++ src/service.rs | 174 ++-- src/store.rs | 117 ++- src/web.rs | 266 ++++-- static/app.css | 19 + static/app.js | 44 +- static/index.html | 214 ++++- static/locales/messages.json | 586 ++++++++++--- tests/access.rs | 1267 +++++++++++++++++++++++++++ tests/browser-absence.cjs | 13 +- tests/browser-access.cjs | 176 ++++ tests/browser-advanced-demos.cjs | 2 +- tests/browser-binding-popover.cjs | 2 +- tests/browser-binding-preview.cjs | 2 +- tests/browser-calendars.cjs | 21 +- tests/browser-configuration.cjs | 17 +- tests/browser-cover.cjs | 14 +- tests/browser-coverage-status.cjs | 2 +- tests/browser-diagnostics.cjs | 2 +- tests/browser-generation-map.cjs | 2 +- tests/browser-generation-scroll.cjs | 2 +- tests/browser-generation.cjs | 15 +- tests/browser-navigation.cjs | 2 +- tests/browser-repeat-defaults.cjs | 2 +- tests/browser-resource-details.cjs | 2 +- tests/browser-rule-result.cjs | 2 +- tests/browser-setup-layout.cjs | 2 +- tests/browser-variable-editor.cjs | 2 +- tests/browser.cjs | 38 +- tests/demo_lifecycle.rs | 326 +++++++ tests/generation.rs | 2 + tests/integration.rs | 352 +------- tests/oidc.rs | 434 +++++++++ tests/support/browser.cjs | 66 +- tests/support/fake_oidc.rs | 293 +++++++ tests/support/mod.rs | 181 +++- 68 files changed, 7220 insertions(+), 964 deletions(-) create mode 100644 deploy/instance-config.example.yaml create mode 100644 src/access.rs create mode 100644 src/admin.rs create mode 100644 src/demo-config.yaml create mode 100644 src/identity.rs create mode 100644 src/migrations/m_0009_access.rs create mode 100644 src/oidc.rs create mode 100644 tests/access.rs create mode 100644 tests/browser-access.cjs create mode 100644 tests/demo_lifecycle.rs create mode 100644 tests/oidc.rs create mode 100644 tests/support/fake_oidc.rs diff --git a/.env.example b/.env.example index 5b349ea..2f8f5b4 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,17 @@ DATABASE_URL=postgresql://meguri:change-me@127.0.0.1:5432/meguri MEGURI_BIND=127.0.0.1:3000 MEGURI_PUBLIC_URL=http://127.0.0.1:3000 MEGURI_ROLE=all -MEGURI_ALLOW_SIGNUP=false +# Instance configuration: workspaces, group mappings and sign-in methods. Required to start, +# except in demo mode (MEGURI_SEED_DEMO=true), which uses a built-in configuration and ignores this. +# See deploy/instance-config.example.yaml. +MEGURI_CONFIG_FILE=/etc/meguri/config.yaml +# Deployment secret of the confidential OIDC client; required when the configuration has an oidc block. +# MEGURI_OIDC_CLIENT_SECRET=from-your-secret-manager +# Allows a configuration without workspaces to disable every existing workspace (normally refused). +# MEGURI_ALLOW_DISABLE_ALL=false MEGURI_SEED_DEMO=false +# Demo only: adds n empty "Sandbox" workspaces for automated browser checks (0-20). +# MEGURI_DEMO_SANDBOXES=0 # Optional separately provisioned empty demo database. Protected databases are never reset. # MEGURI_DEMO_DATABASE_URL=postgresql://demo:change-me@127.0.0.1:5432/meguri_demo MEGURI_DEMO_WORKSPACE_TTL_SECONDS=86400 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d5139fa..88d374e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -37,7 +37,7 @@ jobs: - run: cargo fmt --check - run: cargo clippy --locked --all-targets -- -D warnings - run: cargo test --locked - - run: cargo test --locked --test integration -- --ignored --test-threads=1 + - run: cargo test --locked --tests -- --ignored --test-threads=1 - uses: actions/setup-node@v4 with: node-version: 22 @@ -48,7 +48,7 @@ jobs: env: DATABASE_URL: postgresql://postgres:test-only@postgres:5432/meguri_test MEGURI_SEED_DEMO: "true" - MEGURI_ALLOW_SIGNUP: "true" + MEGURI_DEMO_SANDBOXES: "12" MEGURI_BROWSER_CHANNEL: chromium run: | cargo build --locked diff --git a/AGENTS.md b/AGENTS.md index 713b61c..1ce63bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ All built-in UI text, labels, placeholders, accessible names and errors belong i - `domain`: typed state and permissions; no HTTP, database, or provider dependencies. - `engine`: deterministic schedule calculation with an explicit clock/range; no I/O. - `store` / `service`: Cot ORM and atomic PostgreSQL statement boundaries, authorization, optimistic versions, approval workflow, audit, durable sync requests. +- `access`: pure instance configuration, group-to-role resolution and bootstrap passwords. `identity`: configuration sync, group-derived access, SSO accounts and contact linking. `oidc`: token protocol and verification only. `admin`: instance administrator operations. Anything that grants access goes through `identity::apply_access`. - `providers`: provider translation and HTTP only. Scheduling decisions belong in the engine. Never silently omit an unsupported provider feature. - `worker`: retryable reconciliation, drift checks, publication checkpoints. Multiple replicas must not publish a workspace concurrently. - `web`: thin authenticated handlers. Every workspace operation checks its workspace scope on the server. @@ -20,7 +21,7 @@ All built-in UI text, labels, placeholders, accessible names and errors belong i ## Invariants -1. Workspace permissions are checked on every API and background operation. Membership in one workspace never grants access to another. Workspace owners administer only their workspace. There is no workspace hierarchy or implicit team. +1. Workspace permissions are checked on every API and background operation. Membership in one workspace never grants access to another. Workspace administrators administer only their workspace; instance administrators (a flag derived from `instance_admin_groups` or a bootstrap account) act on every enabled workspace without being members and are the only ones who see instance configuration, users and disabled workspaces. Workspaces, their keys and the groups that grant roles come only from the instance configuration; memberships are derived from stored groups by `identity::apply_access` and never granted any other way, and there is no self-registration. Disabled workspaces are inaccessible to everyone and are never deleted automatically. There is no workspace hierarchy or implicit team. 2. A request is not an override until all required participants approve or a user with the force permission applies it. Every action is audited; its comment is optional, including forced application. Revalidate ownership, availability, and overlaps at application time. A subsequent approved replacement supersedes only overlapping effective spans; preserve original requests and history. 3. Intervals are half-open `[start, end)`. Every schedule has its own handover in the workspace's IANA zone, snapshotted in workspace history. User timezone affects display only and defaults to the browser zone. Reject ambiguous/nonexistent local handovers instead of guessing. Never substitute a fixed UTC offset. 4. Base rotations have an explicit ordered participant list and anchor. An override does not advance or reset the base rotation. @@ -29,13 +30,13 @@ All built-in UI text, labels, placeholders, accessible names and errors belong i 7. External drift blocks publication by default. Overwrite is an explicit workspace policy. Reconcile only owned resources. Persist successful remote writes so partial failures can recover. 8. Provider secrets come from the deployment secret store/environment, never API responses, audit snapshots, examples, or logs. 9. Report approval and publication as separate statuses. Never label an unverified external write as synchronized. -10. Scheduling contacts never gain login or workspace access through an email entry. Country defaults to CY. Imports use the same country codes and are add-only, server-previewed, and revision-checked. Resolve external identities exactly before remote writes; never guess accounts from similar names. +10. Scheduling contacts never gain login or workspace access through an email entry. At SSO sign-in an email may bind an existing contact in a workspace the user already belongs to (the contact ID is replaced by the user ID and kept as an alias); this identifies a person but never grants access, and ambiguous matches are skipped. SSO identities are never linked to existing password accounts automatically. Country defaults to CY. Imports use the same country codes and are add-only, server-previewed, and revision-checked. Resolve external identities exactly before remote writes; never guess accounts from similar names. ## Required workflow Implement a small coherent change; add meaningful regression tests for scheduling, authorization, workflow, or reconciliation behavior it changes. Run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and the PostgreSQL integration suite described in `docs/development.md`. Inspect the actual browser for UI work. Do not add tests that only mirror constants or formatting. -Do not silently skip unavailable checks: record the limitation. Keep `Cargo.lock` committed for this application. New schema changes get a new migration; never edit an applied migration. Do not commit credentials, local database files, build output, or `opsgenie-master/`. +Do not silently skip unavailable checks: record the limitation. Keep `Cargo.lock` committed for this application. New schema changes get a new migration; never edit an applied migration. Instance configuration is declarative and owns workspaces and group mappings; never add a UI or API that creates workspaces or edits those mappings. The OIDC client secret and bootstrap passwords never enter configuration files, API responses, audit or logs other than the single documented bootstrap log line. Do not commit credentials, local database files, build output, or `opsgenie-master/`. The ignored Terraform directory is reference material only. Store anonymized behavioral examples and tests in the repository so other agents do not depend on it. Preserve its business patterns, not its historical workarounds (manual DST tables, cached remote on-call lookup, multiple Terraform applies). diff --git a/Cargo.lock b/Cargo.lock index 35dc6d0..652a867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,12 +261,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -455,6 +467,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -651,6 +669,18 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -700,6 +730,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.20.11" @@ -769,6 +826,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -839,6 +907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common 0.1.7", "subtle", ] @@ -871,6 +940,44 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -880,6 +987,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -940,6 +1068,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.13" @@ -1083,6 +1227,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1143,6 +1288,17 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1191,13 +1347,31 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -1312,7 +1486,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1502,6 +1676,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "lasso" version = "0.7.3" @@ -1516,6 +1714,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1523,6 +1724,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1590,15 +1797,19 @@ name = "meguri" version = "0.1.0" dependencies = [ "argon2", + "base64 0.23.1", "chrono", "chrono-tz", "cot", "csv", "email_address", + "hmac 0.12.1", "http-body-util", + "jsonwebtoken", "reqwest", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.10.9", "thiserror", "tokio", @@ -1704,12 +1915,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1717,6 +1973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1740,6 +1997,30 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -1792,6 +2073,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1864,6 +2164,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1894,6 +2215,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1995,6 +2325,8 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -2004,7 +2336,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -2019,6 +2351,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2094,7 +2436,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -2126,6 +2468,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2140,6 +2492,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -2233,6 +2605,20 @@ dependencies = [ "sqlx", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "securer-string" version = "0.1.3" @@ -2336,6 +2722,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2394,12 +2793,34 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -2440,6 +2861,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -2459,7 +2890,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "chrono", @@ -2560,7 +2991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -2571,8 +3002,8 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", @@ -2940,7 +3371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "futures", "http", "parking_lot", @@ -3084,6 +3515,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3490,6 +3927,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 37d7403..f94c716 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ tower-http = { version = "0.6", features = ["trace", "limit"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1.23", features = ["v4", "serde"] } +serde_yaml_ng = "0.10.0" +hmac = "0.12" +base64 = "0.23.1" +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] } [dev-dependencies] http-body-util = "0.1" diff --git a/README.md b/README.md index 1982a2a..b4dbc44 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ On Windows with the PostgreSQL server binaries installed: ./scripts/dev.ps1 ``` -Open http://127.0.0.1:3000. Explicit demo accounts: `alice`, `bob`, `carol`; password `meguri-demo-only`. Stop with `./scripts/stop-dev.ps1`. Pass `-PostgresBin PATH` if PostgreSQL is elsewhere. Local data and logs live in ignored `target/local/`. +Open http://127.0.0.1:3000. The script starts disposable demo mode: accounts `admin` (instance administrator), `alice` (administrator of the demo workspaces) and `bob`, `carol`, `dave`, `erin` (employees) share one password that is generated on every start and shown on the login screen and in `target/local/app.log`. Stop with `./scripts/stop-dev.ps1`. Pass `-PostgresBin PATH` if PostgreSQL is elsewhere. Local data and logs live in ignored `target/local/`. -On other platforms, create a PostgreSQL database, set the environment variables in `.env.example`, then run `cargo run --locked`. Use `postgresql://` URLs. Enable `MEGURI_ALLOW_SIGNUP=true` to create accounts; `MEGURI_SEED_DEMO=true` works only on loopback. Environment files are not automatically loaded. +On other platforms, create a PostgreSQL database, set the environment variables in `.env.example`, then run `cargo run --locked`. Use `postgresql://` URLs. `MEGURI_SEED_DEMO=true` starts the disposable demo and works only on loopback. Any other run needs an instance configuration file (`MEGURI_CONFIG_FILE`, see [`deploy/instance-config.example.yaml`](deploy/instance-config.example.yaml)); without it startup fails. Environment files are not automatically loaded. ## Implemented -- Independent workspaces, local accounts, granular workspace grants, and durable audit/history. +- Independent workspaces declared in an instance configuration file, OpenID Connect sign-in with group-to-role mapping (instance administrator, workspace administrator, employee), optional password and bootstrap accounts, and durable audit/history. - Calendar-day rotations with IANA timezones, ordered participants, anchors, absence fallback, holidays, and escalation backup schedules. - Replacements and atomic reciprocal swaps; confirmation by the other employee or forced application with a reason. - Durable publication, optimistic revisions, worker leases, retries, checkpoints, external drift protection, and a downloadable preview. @@ -30,15 +30,15 @@ Start with [AGENTS.md](AGENTS.md). Describe intended behavior and examples to th cargo fmt --check cargo clippy --all-targets -- -D warnings cargo test -cargo test --test integration -- --ignored --test-threads=1 +cargo test --locked --tests -- --ignored --test-threads=1 ``` -The integration command requires `MEGURI_TEST_DATABASE_URL` for a disposable PostgreSQL database. CI runs it explicitly; ordinary unit tests need no database or external account. Browser checks are in `tests/browser.cjs`. +The ignored suites require `MEGURI_TEST_DATABASE_URL` for a disposable PostgreSQL database whose user may create databases. CI runs them explicitly; ordinary unit tests need no database or external account. Browser checks (`npm run test:browser`) run against a freshly started demo. ## Current boundaries -OIDC identity linking has a schema boundary but no login adapter yet. JSM has local HTTP contract coverage and still needs acceptance testing against a dedicated JSM test team and your subscription. GKE manifests are deployment templates, not an already deployed environment. No alert ingestion or notification engine is included. +Sign-in is OpenID Connect (code flow with PKCE) and was tested against a fake provider only; verify your Keycloak client and group mapper as described in [deployment](docs/deployment.md#single-sign-on-with-keycloak) before relying on it. There is no self-registration and no in-app workspace creation. JSM has local HTTP contract coverage and still needs acceptance testing against a dedicated JSM test team and your subscription. GKE manifests are deployment templates, not an already deployed environment. No alert ingestion or notification engine is included. The ignored `opsgenie-master/` directory is local reference material. Its behavioral examples are captured in the product contract and tests; application and CI do not depend on it. -Demo mode is disposable: `MEGURI_SEED_DEMO=true` resets only positively marked demo data on restart and expires temporary resources while running. Unmarked/normal databases are protected and trigger an isolated demo database with a permanent warning. See [demo lifecycle and retention settings](docs/development.md#demo-lifecycle). Alice leads two generated examples; Bob demonstrates Engineering on-call, Carol daily Support shifts. All use `meguri-demo-only`. +Demo mode is disposable: `MEGURI_SEED_DEMO=true` resets only positively marked demo data on restart and expires temporary resources while running. Unmarked/normal databases are protected and trigger an isolated demo database with a permanent warning. See [demo lifecycle and retention settings](docs/development.md#demo-lifecycle). Alice administers four generated examples; Bob demonstrates Engineering on-call, Carol daily Support shifts. diff --git a/deploy/gke.yaml b/deploy/gke.yaml index 539a872..83ddf78 100644 --- a/deploy/gke.yaml +++ b/deploy/gke.yaml @@ -9,6 +9,71 @@ metadata: --- apiVersion: v1 kind: ConfigMap +metadata: + name: meguri-instance-config +data: + # Declarative instance configuration (see deploy/instance-config.example.yaml for the commented + # reference and docs/deployment.md). Replace the placeholders. The migrate Job synchronizes it; + # replicas only read it. Change it, run the migrate Job for the release, then restart replicas. + config.yaml: | + auth: + # Lets ordinary password accounts (created by an instance administrator) sign in. + # Keep false in production where everyone signs in through SSO. + password_login: false + + # Break-glass instance administrators. Omit this whole block in production; add it only to get + # back in when SSO is broken, then remove it again. While present, the generated password is + # written to the log of the process that synchronized the configuration (the migrate Job), and + # anyone who can read that log can sign in as an instance administrator. The password is derived + # from a database-held secret, the username and the current UTC hour; it rotates every hour. + # bootstrap_admin: + # usernames: [rescue] + + # OpenID Connect provider. Confidential client, authorization-code flow with PKCE. + # Redirect URI to register: {MEGURI_PUBLIC_URL}/api/oidc/callback + oidc: + issuer: https://sso.example.internal/realms/corp # HTTPS; discovery is read from /.well-known/openid-configuration + client_id: meguri + scopes: [openid, profile, email] # default; must include openid; email links people planned by email + # Where the groups are. A dotted path into the token claims (for example realm_access.roles). + # Keycloak: add a "Group Membership" mapper with this claim name, "Add to ID token" and + # "Add to userinfo" on. Matching below is by exact string, so "Full group path" decides + # whether values look like /payments/leads or leads. + groups_claim: groups + groups_sources: [id_token, userinfo] # searched in this order + + # Groups whose members are instance administrators: they see and may act in every enabled + # workspace, manage password users and delete disabled workspaces. Not tied to any workspace. + instance_admin_groups: + - /meguri/admins + + # Workspaces. `key` is the permanent identifier (3-64 characters: a-z, 0-9, hyphen); never reuse + # it for something else. `name` is unique and is what people see; changing it renames the + # workspace. A workspace that exists without a key and has exactly this name is adopted. + # Removing an entry here DISABLES the workspace (nobody can open it, nothing is published, data is + # kept); listing it again re-enables it. Deleting it for good is a manual action on the + # instance administrator's page. + workspaces: + - key: payments + name: Payments + # Workspace administrators (team leads): every permission in this workspace only. At least one group. + admin_groups: + - /payments/leads + # Employees: the workspace's employee permission set, by default calendar, schedule editing + # and replacement requests. Administrators change that set in the workspace settings. + member_groups: + - /payments/team + + - key: customer-support + name: Customer Support + admin_groups: + - /support/leads + member_groups: + - /support/team + - /support/contractors +--- +apiVersion: v1 +kind: ConfigMap metadata: name: meguri-config data: @@ -16,7 +81,7 @@ data: MEGURI_PUBLIC_URL: https://meguri.example.internal MEGURI_ROLE: all MEGURI_SKIP_MIGRATIONS: "true" - MEGURI_ALLOW_SIGNUP: "false" + MEGURI_CONFIG_FILE: /etc/meguri/config.yaml MEGURI_SEED_DEMO: "false" RUST_LOG: meguri=info,cot=warn --- @@ -52,6 +117,9 @@ spec: httpGet: {path: /startup, port: 9090} periodSeconds: 2 failureThreshold: 30 + volumes: + - name: instance-config + configMap: {name: meguri-instance-config} containers: - name: meguri image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE @@ -59,6 +127,12 @@ spec: envFrom: - configMapRef: {name: meguri-config} - secretRef: {name: meguri-secrets} + env: + - name: MEGURI_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: {name: meguri-secrets, key: MEGURI_OIDC_CLIENT_SECRET, optional: true} + volumeMounts: + - {name: instance-config, mountPath: /etc/meguri, readOnly: true} securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false diff --git a/deploy/instance-config.example.yaml b/deploy/instance-config.example.yaml new file mode 100644 index 0000000..b46cae6 --- /dev/null +++ b/deploy/instance-config.example.yaml @@ -0,0 +1,63 @@ +# Meguri instance configuration. The process refuses to start without it (MEGURI_CONFIG_FILE). +# This file is the only place that defines workspaces and which identity-provider groups give +# which role; there is no UI or API for either. The OIDC client secret is NOT stored here: set +# MEGURI_OIDC_CLIENT_SECRET in the deployment secret store. +# +# Applied by the migrate Job (or a single process without MEGURI_SKIP_MIGRATIONS) at every release. +# Unknown fields and invalid values are rejected at startup. + +auth: + # Lets ordinary password accounts (created by an instance administrator) sign in. + # Keep false in production where everyone signs in through SSO. + password_login: false + + # Break-glass instance administrators. Omit this whole block in production; add it only to get + # back in when SSO is broken, then remove it again. While present, the generated password is + # written to the log of the process that synchronized the configuration (the migrate Job), and + # anyone who can read that log can sign in as an instance administrator. The password is derived + # from a database-held secret, the username and the current UTC hour; it rotates every hour. + # bootstrap_admin: + # usernames: [rescue] + +# OpenID Connect provider. Confidential client, authorization-code flow with PKCE. +# Redirect URI to register: {MEGURI_PUBLIC_URL}/api/oidc/callback +oidc: + issuer: https://sso.example.internal/realms/corp # HTTPS; discovery is read from /.well-known/openid-configuration + client_id: meguri + scopes: [openid, profile, email] # default; must include openid; email links people planned by email + # Where the groups are. A dotted path into the token claims (for example realm_access.roles). + # Keycloak: add a "Group Membership" mapper with this claim name, "Add to ID token" and + # "Add to userinfo" on. Matching below is by exact string, so "Full group path" decides + # whether values look like /payments/leads or leads. + groups_claim: groups + groups_sources: [id_token, userinfo] # searched in this order + +# Groups whose members are instance administrators: they see and may act in every enabled +# workspace, manage password users and delete disabled workspaces. Not tied to any workspace. +instance_admin_groups: + - /meguri/admins + +# Workspaces. `key` is the permanent identifier (3-64 characters: a-z, 0-9, hyphen); never reuse +# it for something else. `name` is unique and is what people see; changing it renames the +# workspace. A workspace that exists without a key and has exactly this name is adopted. +# Removing an entry here DISABLES the workspace (nobody can open it, nothing is published, data is +# kept); listing it again re-enables it. Deleting it for good is a manual action on the +# instance administrator's page. +workspaces: + - key: payments + name: Payments + # Workspace administrators (team leads): every permission in this workspace only. At least one group. + admin_groups: + - /payments/leads + # Employees: the workspace's employee permission set, by default calendar, schedule editing + # and replacement requests. Administrators change that set in the workspace settings. + member_groups: + - /payments/team + + - key: customer-support + name: Customer Support + admin_groups: + - /support/leads + member_groups: + - /support/team + - /support/contractors diff --git a/deploy/migrate.yaml b/deploy/migrate.yaml index 73a48db..8564d27 100644 --- a/deploy/migrate.yaml +++ b/deploy/migrate.yaml @@ -28,6 +28,9 @@ spec: httpGet: {path: /startup, port: 9090} periodSeconds: 2 failureThreshold: 30 + volumes: + - name: instance-config + configMap: {name: meguri-instance-config} containers: - name: migrate image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE @@ -37,6 +40,11 @@ spec: env: - {name: MEGURI_ROLE, value: migrate} - {name: MEGURI_SKIP_MIGRATIONS, value: "false"} + - name: MEGURI_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: {name: meguri-secrets, key: MEGURI_OIDC_CLIENT_SECRET, optional: true} + volumeMounts: + - {name: instance-config, mountPath: /etc/meguri, readOnly: true} securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false diff --git a/docs/architecture.md b/docs/architecture.md index 72f7899..a87d665 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index 9213d05..bc34c6f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,13 +4,60 @@ Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workloa 1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine). 2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment. -3. Create the Kubernetes service account and ConfigMap from `gke.yaml`, then the `meguri-secrets` Secret through your deployment secret manager. It needs `DATABASE_URL=postgresql://USER:PERCENT_ENCODED_PASSWORD@127.0.0.1:5432/meguri`. Proxy-to-database transport is encrypted; loopback stays inside the pod. Add tenant-specific JSM `_EMAIL` / `_TOKEN` variables only for configured tenants. Never commit rendered secrets. -4. Apply only `migrate.yaml`, wait for its successful completion, then apply the Deployment, Service and PDB. Migrations use the same image with `MEGURI_ROLE=migrate`. Replicas have `MEGURI_SKIP_MIGRATIONS=true`; do not race migrations during rollout. -5. Add your internal HTTPS ingress. `MEGURI_PUBLIC_URL` must exactly match the browser origin (scheme, host, port); mutations and cookies depend on it. The default Service is ClusterIP. Keep access inside your organization while OIDC is being added. To bootstrap local accounts, temporarily enable signup in that restricted environment, create accounts, then disable signup. Never enable demo seeding outside loopback. -6. Run health checks, a two-user replacement, a tenant-isolation check, and the provider acceptance checklist in `testing.md`. Enable real JSM integration only after preview and acceptance. +3. Write the instance configuration (see below; start from `deploy/instance-config.example.yaml`) into the `meguri-instance-config` ConfigMap in `gke.yaml`. Create the Kubernetes service account and ConfigMaps from `gke.yaml`, then the `meguri-secrets` Secret through your deployment secret manager. It needs `DATABASE_URL=postgresql://USER:PERCENT_ENCODED_PASSWORD@127.0.0.1:5432/meguri` and, when OIDC is configured, `MEGURI_OIDC_CLIENT_SECRET`. Proxy-to-database transport is encrypted; loopback stays inside the pod. Add tenant-specific JSM `_EMAIL` / `_TOKEN` variables only for configured tenants. Never commit rendered secrets. +4. Apply only `migrate.yaml`, wait for its successful completion, then apply the Deployment, Service and PDB. The Job uses the same image with `MEGURI_ROLE=migrate` and mounts the same configuration: it runs the Cot migrations and then synchronizes the configuration (creates, adopts, renames, enables and disables workspaces, applies bootstrap accounts, recomputes every user's memberships). Replicas have `MEGURI_SKIP_MIGRATIONS=true`; they read the configuration but never synchronize it, so a configuration change takes effect by running the Job for the release first and then restarting replicas. Do not race migrations during rollout. A bootstrap password, if enabled, is in the Job's log. +5. Add your internal HTTPS ingress. `MEGURI_PUBLIC_URL` must exactly match the browser origin (scheme, host, port); mutations, cookies and the OIDC redirect URI (`{MEGURI_PUBLIC_URL}/api/oidc/callback`) depend on it. The default Service is ClusterIP. There is no registration or manual account bootstrap: people appear when they sign in with SSO and their groups map to a workspace or instance role. For a first deployment before SSO works, list a bootstrap account (below), sign in with the logged password, fix the setup, then remove the block. Never enable demo seeding outside loopback. +6. Run health checks, a two-user replacement, a workspace-isolation check (an employee of one workspace cannot see another), a sign-in with a user whose groups map to nothing (expect the explanatory message and no new account), and the provider acceptance checklist in `testing.md`. Enable real JSM integration only after preview and acceptance. + +## Instance configuration + +Meguri refuses to start without `MEGURI_CONFIG_FILE` (every role; only demo mode uses a built-in file). The file is YAML; unknown fields and invalid values fail startup with a message. It is the only place where workspaces and the groups that grant access are defined; the application has no UI or API for creating workspaces or editing group mappings. + +```yaml +auth: + password_login: false # ordinary password accounts may sign in (default false) + bootstrap_admin: # optional break-glass instance administrators; omit in production + usernames: [rescue] +oidc: # optional; the client secret is MEGURI_OIDC_CLIENT_SECRET + issuer: https://sso.example.internal/realms/corp + client_id: meguri + scopes: [openid, profile, email] # default; must include openid + groups_claim: groups # default; dotted path, e.g. realm_access.roles + groups_sources: [id_token, userinfo] # default; searched in this order +instance_admin_groups: [/meguri/admins] +workspaces: + - key: payments # stable identifier, 3-64 chars: a-z 0-9 - + name: Payments # unique, 1-120 characters + admin_groups: [/payments/leads] # at least one; workspace administrators + member_groups: [/payments/team] # employees +``` + +At least one sign-in method (`oidc`, `password_login` or `bootstrap_admin`) is required, `oidc.issuer` must be HTTPS (loopback may use HTTP), and keys and names must be unique. Groups are compared by exact string: with Keycloak's *Full group path* the values look like `/payments/leads`, without it `leads`. A group in several workspaces is allowed; a person with both an administrator and an employee group of one workspace is an administrator. + +Synchronization (run by the migrate Job, or by a single process without `MEGURI_SKIP_MIGRATIONS`) makes the database match the file: + +- A configured key without a workspace creates it. A workspace that has no key and exactly the configured name is adopted; if several match, startup fails and nothing is changed. A different name renames the workspace as a normal revision. +- A workspace missing from the file is *disabled*, never deleted: nobody can open it (including open sessions), it is not published, its memberships and queued publication are removed, and its data stays. Listing it again re-enables it and queues a publication. If the file lists no workspaces while enabled ones exist, startup is refused unless `MEGURI_ALLOW_DISABLE_ALL=true`; a mistyped key therefore creates a new empty workspace and disables the old one, and fixing the file restores both. +- Disabled workspaces appear on the instance administrator's page. Deleting one is a deliberate, permanent action there (type its key; refused while it is configured or publishing). Remote provider resources it published are not removed. +- Every user's memberships are recomputed from the groups stored at their last sign-in (or set by an administrator), so changed group mappings apply to everyone at the release without waiting for logins. Group membership changes in the identity provider apply at the user's next SSO sign-in (sessions last 12 hours). +- Instance-level events (workspace created/adopted/renamed/enabled/disabled/deleted, bootstrap password set, password user created, groups changed, password reset) are written to `instance_audit`. + +## Single sign-on with Keycloak + +Create a confidential OpenID Connect client for Meguri: client authentication on, *Standard flow* only (no implicit, direct-access or service accounts), valid redirect URI `{MEGURI_PUBLIC_URL}/api/oidc/callback`, and put the generated secret into `MEGURI_OIDC_CLIENT_SECRET`. Request the `openid`, `profile` and `email` scopes; the email is used to link people already planned by email. Add a **Group Membership** mapper to the client with *Add to ID token* and *Add to userinfo* enabled (userinfo is the fallback source) and a token claim name equal to `oidc.groups_claim` (`groups` by default). *Full group path* decides whether values are `/payments/leads` or `leads`; the configuration must use exactly what the token contains. Keycloak does not send groups unless this mapper exists. Meguri verifies RS256 signatures against the realm's JWKS, so keep RS256 as the client's signature algorithm. + +A sign-in whose token carries no groups claim is treated as having no groups; the log shows `the identity provider returned no groups claim` and the user sees the same explanation as for unmapped groups ("the instance could not map your groups") with a short reference. The log entry `SSO sign-in was not completed` carries that reference, the code (`no_access`, `failed`, `unavailable`) and the cause, including the groups that were received. Sign-out ends only the Meguri session; the identity provider session is untouched. + +## Bootstrap administrators + +`auth.bootstrap_admin.usernames` creates the listed accounts as instance administrators at synchronization and logs one line per account: `Bootstrap administrator enabled: sign in as NAME with password PASSWORD`. The password is derived from a secret stored in the database, the username and the current UTC hour, so every process that synchronizes in the same hour logs the same password; it changes in the next hour and ends the account's sessions. Replicas started across an hour boundary can disagree, which a restart resolves. These accounts may sign in even when `password_login` is false. To recover from a broken SSO setup, add the block, run the migrate Job, read its log, sign in, repair, and remove the block: removal clears the password, ends sessions and revokes the rights while keeping the user record. Treat log access as administrator access while the block exists. A bootstrap name that belongs to an SSO account stops synchronization. The default Deployment has two replicas serving both web and workers. Conditional database leases serialize each team's publication. Roles can later be split into `web` and `worker` deployments without changing the binary. On interruption, a completed remote step remains checkpointed; an unfinished lease expires after five minutes. Monitor failed/conflict sync jobs, oldest `last_success`, and `window_end` approaching the current time. Meguri publishes a finite horizon and must keep running to extend it. Configure operational monitoring around these values; Meguri does not send incident notifications itself. Readiness checks PostgreSQL; liveness checks the process. -The current polling worker and history/audit reads target team-scale workloads. Benchmark and add query pagination/queue batching before broad multi-tenant SaaS rollout. Configure ingress-level rate limits in addition to per-process login throttling. Password recovery, session administration, OIDC, history archival, and live provider acceptance are subsequent operational work. +The current polling worker and history/audit reads target team-scale workloads. Benchmark and add query pagination/queue batching before broad multi-tenant SaaS rollout. Configure ingress-level rate limits in addition to per-process login throttling. Password recovery, session administration, history archival, and live provider acceptance are subsequent operational work. + +## Upgrading a database that used local accounts + +Memberships are now derived entirely from user groups. The first configuration sync on an upgraded database recomputes every membership from the stored groups; users created before this change have no groups, so their old workspace memberships are removed until the instance administrator assigns groups (password users) or they sign in through SSO. Workspaces without a configuration key are adopted by exact name or disabled; none is deleted. Review the first sync log and the instance audit before opening the instance to users. diff --git a/docs/development.md b/docs/development.md index a9077e2..4827141 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,9 +2,9 @@ Requirements: Rust 1.97 (Cot itself requires 1.94), PostgreSQL 17+, and a browser. Node is only needed for browser automation or upgrading vendored browser assets; serving the application has no Node build step. -Use `.env.example` as the list of supported settings. Environment files are not loaded automatically. Start a dedicated PostgreSQL database, set `DATABASE_URL`, then run `cargo run`. Database migrations run before the server accepts requests. +Use `.env.example` as the list of supported settings. Environment files are not loaded automatically. Start a dedicated PostgreSQL database and either run the disposable demo (`MEGURI_SEED_DEMO=true`, loopback only, built-in configuration, see below) or write an instance configuration file (start from `deploy/instance-config.example.yaml`; `auth.password_login: true` plus `auth.bootstrap_admin` gives a local administrator without an identity provider), set `DATABASE_URL` and `MEGURI_CONFIG_FILE`, then run `cargo run`. Startup fails without a configuration file. Database migrations and the configuration sync run before the server accepts requests; the generated bootstrap password is logged. There is no registration: create further password users on the instance page, or sign in through an OIDC provider. -For an isolated Windows development database, use `scripts/dev.ps1`. It uses installed PostgreSQL binaries, creates data under ignored `target/`, binds the database and app to loopback, enables registration, and explicitly seeds demonstration accounts. `scripts/stop-dev.ps1` stops only the processes started for this workspace. +For an isolated Windows development database, use `scripts/dev.ps1`. It uses installed PostgreSQL binaries, creates data under ignored `target/`, binds the database and app to loopback, and starts disposable demo mode. The demo password is generated on every start; it appears on the login screen and in `target/local/app.log`. `scripts/stop-dev.ps1` stops only the processes started for this workspace. Required checks: @@ -14,15 +14,15 @@ cargo clippy --all-targets -- -D warnings cargo test ``` -PostgreSQL integration tests require `MEGURI_TEST_DATABASE_URL` pointing at a disposable database, with CREATEDB for the isolated demo-lifecycle tests. Run `cargo test --test integration -- --ignored --test-threads=1`. Most tests create isolated workspaces/users; lifecycle tests create and remove uniquely named databases to test protection, reset, retention, and reseeding. Never use a production database. +PostgreSQL integration tests require `MEGURI_TEST_DATABASE_URL` pointing at a disposable database with CREATEDB (lifecycle and configuration-sync tests create and drop uniquely named databases, because sync disables every unconfigured workspace). Run `cargo test --locked --tests -- --ignored --test-threads=1`; this covers `tests/integration.rs`, `tests/access.rs`, `tests/oidc.rs` and `tests/demo_lifecycle.rs`. Most scheduling tests create uniquely keyed workspaces and users through the real configuration and group path; lifecycle tests test protection, reset, retention, and reseeding. Never use a production database. CI uses an explicit Debian Node job container and the PostgreSQL service hostname `postgres`, for both Gitea Actions and GitHub Actions. Loopback inside the job does not reach the database service. No host database port is published. A bounded authenticated `psql SELECT 1` check runs before compilation; database networking/authentication failures stop the job rather than producing a pool timeout in every integration test. The application and browser tests still share job-container loopback. -Browser checks: sign in; inspect the calendar; create a request as one user and approve it as another; verify the publication status separately; verify that a user outside the workspace cannot read it. Use both desktop and narrow viewports. A test failure is not a reason to relax an authorization or scheduling invariant. +Browser checks: sign in with a demo account; inspect the calendar; create a request as one user and approve it as another; verify the publication status separately; verify that a user outside the workspace cannot read it. Use both desktop and narrow viewports. A test failure is not a reason to relax an authorization or scheduling invariant. ## Operational limitations -For automated browser checks, run `npm ci`, `npx playwright install chromium`, then `npm run test:browser` against a seeded local app with signup enabled. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium. The original suite creates its own workspace. `tests/browser-calendars.cjs` creates an isolated test account and checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` uses another isolated account to check removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`. +For automated browser checks, run `npm ci`, `npx playwright install chromium`, then `npm run test:browser` against a freshly started demo (`MEGURI_SEED_DEMO=true`). The suites read the generated password from `/api/session` (`tests/support/browser.cjs`), so they need no configuration; set `MEGURI_BROWSER_URL` for a non-default address. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium or `MEGURI_BROWSER_EXECUTABLE` for a specific binary. Workspaces cannot be created from the UI or API, so suites that need an isolated workspace create a fresh password user through the instance administrator API with groups from the demonstration configuration. `tests/browser-access.cjs` covers the login guide, absence of registration, the SSO error banner, the instance page and its read-only rules, employee permission editing (including force without approval), employee and outsider views, and workspace isolation. `tests/browser-calendars.cjs` checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` checks removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`. The suites modify demo data (some empty a demo workspace); restart the demo before repeating a run. Windows development needs complete PostgreSQL server binaries and their `share/` data files, not a client-only installation. Pass `-PostgresBin` to use a portable distribution. The loopback-only local database uses trust authentication and is not a production configuration. The script serves a copy of the executable so Cargo can rebuild while it runs. @@ -30,17 +30,17 @@ Read [testing.md](testing.md) when changing rules and [deployment.md](deployment ## Demo lifecycle -`MEGURI_SEED_DEMO=true` enables disposable demo mode, not just seed insertion. It is limited to loopback and a single `all` or `web` process. Startup verifies the previous database mode before migrations, resets only positively marked demo application data, migrates, and seeds two examples. Normal startup records `normal`, including migration/worker roles. A missing or unreadable provenance marker never grants deletion authority. The existing development `meguri` DB will therefore be preserved on its first transition to this mode. +`MEGURI_SEED_DEMO=true` enables disposable demo mode, not just seed insertion. It is limited to loopback and a single `all` or `web` process, uses the built-in configuration `src/demo-config.yaml` (any `MEGURI_CONFIG_FILE` is ignored) and needs no OIDC. Startup verifies the previous database mode before migrations, resets only positively marked demo application data, migrates, synchronizes the built-in configuration and seeds four examples. Normal startup records `normal`, including migration/worker roles. A missing or unreadable provenance marker never grants deletion authority. The existing development `meguri` DB will therefore be preserved on its first transition to this mode. When the configured DB is protected, Meguri selects a separate `meguri_demo__` DB. The application login needs CREATEDB, or an administrator must supply an empty `MEGURI_DEMO_DATABASE_URL` with suitable permissions. Credentials are never shown in the browser/log notice. Protected fallback candidates are skipped, never dropped or overwritten. Isolation failure aborts startup. Do not manually mark a production database as demo. Another active client blocks reset; stop the old demo process before restarting. -The permanent header explains creation-based expiry and any protected-DB fallback. Defaults: workspaces 24 hours; visitor accounts 48 hours; requests 2 hours; audit/history 6 hours; physical purge 10 minutes after soft deletion. Configure seconds with the `MEGURI_DEMO_*` settings in `.env.example` (60 seconds–30 days). Sweeps run every minute. Nested configuration expires with its owning aggregate to preserve dependencies. Seed accounts remain; baseline workspaces are reseeded after expiry. No cleanup runs in normal mode. Demo rejects real provider integrations/publication. +The permanent header explains creation-based expiry and any protected-DB fallback. Defaults: workspaces 24 hours; accounts created by an administrator during the run 48 hours; requests 2 hours; audit/history 6 hours; physical purge 10 minutes after soft deletion. Configure seconds with the `MEGURI_DEMO_*` settings in `.env.example` (60 seconds–30 days). Sweeps run every minute and also re-run the configuration sync and seeding. Nested configuration expires with its owning aggregate to preserve dependencies. The seeded accounts remain; baseline workspaces are recreated and reseeded after expiry. No cleanup runs in normal mode. Demo rejects real provider integrations/publication. -Alice (`meguri-demo-only`) leads both examples. Bob belongs to Engineering; Carol belongs to Support. Engineering uses `projects`, `responsible`, and `on_call`; Support has independent `queues`, `agents`, and `lead` variables. All generated state is activated through the regular preview/commit code path with the mock provider. +Accounts (one password per start, shown on the login screen and in the log; there is no registration): `admin` is the instance administrator (a bootstrap account), `alice` administers all four workspaces, and `bob`, `carol`, `dave`, `erin` are employees. Groups are assigned as an instance administrator would assign them, and the built-in configuration is in the production format. Bob belongs to Engineering and Regional Platform; Carol to Engineering, Support and Commerce; Dave and Erin to Engineering. Engineering uses `projects`, `responsible`, and `on_call`; Support has independent `queues`, `agents`, and `lead` variables. All generated state is activated through the regular preview/commit code path with the mock provider. Additional browser suites: `browser-absence.cjs` checks collapsed holidays, DD-MM-YYYY input, live bulk absence impact, range replacements and force; `browser-generation.cjs` checks demos, their permanent notice and isolated memberships, visual data/binding editing, early errors, preview without writes, stable generated IDs, and mobile forms. Restart after browser tests to return to the four clean examples. -OIDC is intentionally deferred; local password authentication is implemented first. No external JSM account is needed to run the mock provider. JSM writes require explicit integration configuration and workspace-scoped environment credentials. Contract tests use a fake HTTP server, never a production account. +Authentication is OpenID Connect plus optional password accounts; see [deployment](deployment.md#instance-configuration) for configuration and Keycloak setup. No external JSM account is needed to run the mock provider. JSM writes require explicit integration configuration and workspace-scoped environment credentials. Contract tests use a fake HTTP server, never a production account. Workspace configuration edits take effect when saved. Historical queries use saved configuration revisions. There is no user-configurable delayed activation of a whole configuration revision in the first release. @@ -51,9 +51,9 @@ Navigation and language coverage: tests/browser-navigation.cjs checks direct men Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner and 18 Japanese holidays/days off from https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html (2026 table, verified 2026-09-24). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation. -Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice owns all four; Bob is a member in Acme/Regional, Carol in Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview. +Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice administers all four; Bob is an employee in Acme/Regional, Carol in Acme/Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview. -The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. +The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also has Support/Commerce access through her groups. Other demo workspaces remain separate. The login password is the one generated for the run. ## Single workspace schema @@ -62,3 +62,7 @@ Migration 8 replaces the former tenant/team tables with workspaces and workspace The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation. Generation map uses locally vendored Cytoscape.js 3.33.1 (`static/vendor/cytoscape.js`, adjacent MIT license), with no CDN requests or Node build requirement. Its preset lane layout and inspector live in `static/generation-map.js` / `.html`; engine trace data is returned by the authorized generation-preview endpoint. Run `node tests/browser-generation-map.cjs` against the disposable demo for nested iterations, filtering, diagnostics, read-only behavior, canvas interactions and mobile layout. + +## Browser-check sandboxes + +Browser suites that change data cannot reuse the example workspaces: approved replacements are permanent history, so a schedule that has one can never be removed. Start the demo with `MEGURI_DEMO_SANDBOXES=12` to add empty `Sandbox 1..12` workspaces, each with its own administrator group `sandbox-N-leads` and employee group `sandbox-N-staff`. Every mutating suite creates a throwaway password user in its own sandbox through the instance-administrator API. Restart the demo before each full run; sandboxes are not reset. diff --git a/docs/product.md b/docs/product.md index 4bbecf8..109906c 100644 --- a/docs/product.md +++ b/docs/product.md @@ -1,6 +1,24 @@ # Product contract -Meguri is a multi-tenant on-call configuration service. Workspace is the single tenant boundary: there are no nested teams. A user may belong to several independent workspaces. Workspace grants separately control reading, schedule editing, replacement requests, forced application, membership administration, provider configuration, and audit access. Workspace owners administer their workspace and its drift policy. +Meguri is a multi-tenant on-call configuration service. Workspace is the single tenant boundary: there are no nested teams. A user may belong to several independent workspaces. Three roles exist: instance administrators, workspace administrators, and employees. Workspaces and the groups that grant roles are declared in the instance configuration; the application never lets anyone create them. + +## Access and administration + +**Instance configuration.** Everything about who may enter which workspace is declared in one file read at startup (`MEGURI_CONFIG_FILE`, YAML; JSON is valid YAML). The process refuses to start without it, except demo mode, which carries a built-in configuration of the same format. Unknown fields and invalid values fail startup with the reason. The file declares: `auth.password_login` (default false); optional `auth.bootstrap_admin.usernames`; optional `oidc` (`issuer`, `client_id`, `scopes` default `openid profile email`, `groups_claim` default `groups` as a dotted path, `groups_sources` default `[id_token, userinfo]`); `instance_admin_groups`; and `workspaces`, each with a stable `key` (3–64 lowercase letters, digits and hyphens), a unique `name`, at least one `admin_groups` entry and optional `member_groups`. At least one sign-in method must be configured. The OIDC client secret is a deployment secret (`MEGURI_OIDC_CLIENT_SECRET`), never part of the file. [`deploy/instance-config.example.yaml`](../deploy/instance-config.example.yaml) is a commented example. + +**Roles.** An *instance administrator* sees every enabled workspace, may act in it with every permission without being a member, and manages password users and disabled workspaces from the instance page; configuration, workspaces and group mappings are read-only there. A *workspace administrator* holds every permission in exactly that workspace and cannot see, create or affect any other. An *employee* holds the workspace's employee permission set: calendar, schedule editing and replacement requests by default (`view`, `edit_schedules`, `request_overrides`). Administrators change that set in workspace settings and it applies to every current and future employee immediately; `view` is mandatory and `manage_members`/`manage_integration` can never be granted to employees. Granting `force_overrides` lets every employee apply replacements without confirmation; leaving it out requires approval. + +**Groups decide roles.** A person's groups are compared to the configured groups by exact string match (case, slashes and prefixes matter). Workspace administrator beats employee when both match; matching `instance_admin_groups` makes an instance administrator. Memberships are recomputed from the stored groups at every SSO sign-in, whenever an administrator edits a password user's groups, and for every user at each configuration sync, so removing a group or a workspace removes access. A sign-in whose groups map to nothing is refused with an explanation and a support reference; no account is created or kept enabled for it. + +**Accounts.** There is no self-registration. Accounts come into being only through an SSO sign-in whose groups map to something, or when an instance administrator creates a password user (username, display name, password of 12–256 characters, optional email, groups chosen from the configured list). SSO accounts have no password and their groups belong to the identity provider (read-only in Meguri); password accounts have groups edited by an instance administrator, who cannot edit their own, and can have their password reset (which ends their sessions). An SSO identity is never linked to an existing password account automatically. Ordinary password sign-in works only when `auth.password_login` is true. + +**Workspace lifecycle.** On startup the configuration is synchronized: a configured key without a workspace creates one; an unkeyed workspace with exactly the configured name is adopted (several candidates abort startup); a changed name renames it through an ordinary revision; a workspace missing from configuration is disabled, never deleted. A disabled workspace is invisible and inaccessible to everyone including sessions already open, receives no publication and keeps all its data; configuring it again re-enables it with its previous data and queues a publication. A configuration that lists no workspace while enabled ones exist is refused unless `MEGURI_ALLOW_DISABLE_ALL=true`. An instance administrator may permanently delete a workspace that is disabled and not in the configuration by typing its key (its name if it never had one); history, audit and provider checkpoints go with it, but resources already published to a provider are not removed. Instance-level changes are recorded in `instance_audit`. + +**Bootstrap administrators.** Optional break-glass accounts listed in `auth.bootstrap_admin.usernames` are created at startup as instance administrators with a generated password written to the log of the process that synchronized the configuration. The password is derived from a secret held in the database, the username and the current UTC hour, so replicas restarted in the same hour agree; a new hour rotates it and ends the account's sessions. They may sign in even when `auth.password_login` is false. Removing the block locks the accounts (password cleared, sessions ended, administrator rights revoked) but keeps their records. While enabled, anyone who can read the logs can become an instance administrator; production normally omits the block. + +**Single sign-on.** Authorization code with PKCE for a confidential client. The browser carries a one-time state cookie bound to a database record; callbacks without a matching, unused, unexpired record are refused. ID tokens must be RS256 with a valid signature, issuer, audience, expiry and nonce. Groups come from the configured claim in the ID token, then from userinfo; a missing claim is treated as no groups and logged as a provider misconfiguration. Failures return to the login screen with `login_error` of `no_access`, `failed` or `unavailable` and a short reference that support finds in the log. Sign-out ends only the Meguri session. + +**Email linking.** The sign-in email is lowercased. In each workspace the user belongs to, an existing email-only contact with that email becomes the user: the contact's ID is replaced by the user's ID throughout the current document (schedules, absences, requests, variables), the old ID is kept as an alias for history and audit, and the contact's name and country are kept. A user without a matching contact gains a person carrying the email, which providers need to resolve them. Ambiguous or conflicting data (several contacts, a contact already tied to another login, a person with a different email) is skipped. An email never grants access; only groups do. ## Scheduling @@ -46,7 +64,7 @@ Schedule removal shows a server-generated dependency summary before applying. Us Routing rules are evaluated from top to bottom. Drag-and-drop and keyboard/touch move buttons save the ordered list of rule IDs; the server requires an exact permutation and assigns consecutive priorities. The rule editor selects an insertion position instead of exposing conflicting numeric priorities. Reordering requires schedule-edit permission and rejects stale versions. -Local password authentication is the initial identity adapter. User IDs, memberships, sessions, and audit identities are provider-independent; later OIDC identities bind `(issuer, subject)` to an existing local user ID. Demo mode must be explicitly enabled and uses only the local preview provider. +Identity is provider-independent: user IDs, memberships, sessions and audit identities are internal; an SSO identity binds `(issuer, subject)` to one user. Sessions last 12 hours; sign-in attempts are throttled per username. Demo mode must be explicitly enabled and uses only the local preview provider. Daily use starts on the calendar with incoming requests and one-click Accept/Decline, outgoing pending requests, and the selected schedule's current/next personal shift. Schedules are searchable. Clicking a duty day prefills owner, schedule and exact local boundaries. Own days offer searchable teammates; other people's days preselect the signed-in employee. An exact incoming request can be accepted directly from that day. Split days explicitly choose a segment; past days are read-only. Start/end dates and clock times are always visible and are validated on the server before send. Reciprocal swaps are optional and have their own explicit start/end boundaries. Force permission adds a separate Apply without approval action. @@ -56,9 +74,9 @@ Infrequent administration lives under Setup. Dialogs name their action. Project- Before migrations or deletion, startup checks a separate database provenance marker recording the last runtime mode. Only positive previous-demo provenance permits resetting application data. An unmarked database, a normal-mode database, or an unreadable marker is protected. Demo startup switches to a separately named database (or an explicitly supplied empty demo database), with a permanent prominent header and server-log warning requiring administrator attention. An empty database can be claimed only after proving it has no user relations. If isolation cannot be established, startup fails without changing protected data. No DROP DATABASE, schema deletion, or TRUNCATE CASCADE is used. Migration history and runtime metadata survive resets; another active database client blocks reset. -During a run, a minute-based maintenance task soft-deletes visitor workspaces and their configuration graphs after 24 hours from creation, and visitor accounts after 48 hours. They disappear from access/listing immediately; dependent data is physically removed after a ten-minute grace period, preserving referential integrity. Nested schedules, contacts, variables, rules, absences, and memberships expire with their owning workspace rather than leaving broken references. Requests expire independently after two hours from creation; audit/history have six-hour retention with a historical boundary snapshot retained. Editing never extends creation-based lifetime. Times are configurable. Demo accounts remain usable; seeded workspaces refresh after expiry. The permanent header describes retention and restart reset. Normal mode never runs demo retention. +During a run, a minute-based maintenance task soft-deletes demonstration workspaces and their configuration graphs after 24 hours from creation, and accounts that an instance administrator created during the run after 48 hours. They disappear from access/listing immediately; dependent data is physically removed after a ten-minute grace period, preserving referential integrity. Nested schedules, contacts, variables, rules, absences, and memberships expire with their owning workspace rather than leaving broken references. Requests expire independently after two hours from creation; audit/history have six-hour retention with a historical boundary snapshot retained. Editing never extends creation-based lifetime. Times are configurable. The seeded accounts are pinned and remain usable; seeded workspaces are re-created from the demonstration configuration and reseeded after expiry. The permanent header describes retention and restart reset. Normal mode never runs demo retention. -The minimal examples are Acme Engineering / Project matrix (six services with two or three ordered owners during working hours and a shared weekly Monday 11:00 Time off rotation) and Customer Support / Daily support (daily 09:00 shifts and a 15-minute escalation). Alice leads both, Bob, Dave and Erin demonstrate Engineering swaps; Carol participates in Engineering and demonstrates Support approvals. The Commerce example includes an email-only contact without login access. +The minimal examples are Acme Engineering / Project matrix (six services with two or three ordered owners during working hours and a shared weekly Monday 11:00 Time off rotation) and Customer Support / Daily support (daily 09:00 shifts and a 15-minute escalation). Demo mode uses the built-in configuration `src/demo-config.yaml`, which has the same format as a production file, and signs in only with passwords (no SSO, no registration). Every start generates one password for all demonstration accounts, writes it to the log and shows the account list with the password on the login screen. `admin` is the instance administrator (a bootstrap account); `alice` administers all four workspaces; `bob`, `carol`, `dave` and `erin` are employees. Bob, Dave and Erin demonstrate Engineering swaps; Carol participates in Engineering and demonstrates Support approvals. The Commerce example includes an email-only contact without login access. ## Anonymized reference behavior @@ -67,7 +85,7 @@ Alice, Bob, and Carol rotate weekly from Monday at 11:00. Payments primary is Al Project escalation: primary at minute 0, secondary at minute 10, all team members at minute 30, if unacknowledged. General escalation: on-call at minute 0, random team member at minute 30, named lead at minute 45. Both may repeat 10 times, reset recipient states, and leave the alert open. Working-hours routes select project policies on weekdays 11:00–19:00; a catch-all route selects the general policy. Development alerts may be routed to no notification. ## People, calendars, and working intervals -Scheduling people are separate from login accounts. A workspace can select its members or add an email-only contact without creating an account or granting access. Their name, email, and ISO country belong to the versioned workspace state. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable; an email is never proof of identity. Only authenticated, authorized users can approve requests; an email-only recipient requires a lead's explicit forced application until a verified account-link workflow exists. Selecting a workspace user does not grant them workspace access. +Scheduling people are separate from login accounts. A workspace can select its members (people who reached it through groups) or add an email-only contact without creating an account or granting access. Their name, email, and ISO country belong to the versioned workspace state. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable and an email never grants access; at SSO sign-in it can bind a contact to the signing-in user (see Access and administration). Only authenticated, authorized users can approve requests; an email-only recipient requires an administrator's explicit forced application until that person signs in and the contact is linked. Selecting a workspace user does not grant them workspace access. Each schedule has its own local handover time (11:00 by default) in the workspace's IANA timezone. Duration is still measured in calendar days. Dependencies resolve their own handover boundaries. Workspace working days, start/end time, and IANA timezone are shared by all routing rules with “Only during working hours” enabled. Changing this workspace interval atomically updates workspace snapshots/revisions and queues publication. Old migrations preserve historical handovers and timezone values. @@ -156,7 +174,7 @@ The rule editor materializes absent optional escalation repeat count/wait fields Server-preview changes use labeled green/create, blue/update, red/remove and gray/unchanged badges. The expanded list scrolls at 320px (280px on mobile), with a compact footer toggle. -The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. +The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Access comes from groups defined in the built-in demo configuration, exactly as in production; the accounts share one password generated at every start. Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. diff --git a/docs/testing.md b/docs/testing.md index 6120744..d8cef2a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,9 +10,13 @@ Scheduling errors are product failures. A change to ownership, time boundaries, | Configuration graph | `src/configuration.rs` tests | None | Dependency removal/redirect, last-step and fallback protection, pending swap cancellation, approved-history retention, exact route permutation | | CSV import | `src/holidays.rs` tests | None | Country/date keys, quoting, BOM, duplicates, conflicts, invalid rows | | Provider compilation | `src/providers/` tests | None | Unsupported capabilities fail; normalization detects changed ownership | +| Instance access, pure | `src/access.rs`, `src/identity.rs` tests | None | Configuration validation (unknown fields, key/name/group rules, sign-in method required), exact group matching and administrator-over-employee, claim paths, HMAC bootstrap passwords per hour/user/secret, contact-merge rules, username derivation | | Application/persistence | `tests/integration.rs`, `tests/support/` | Disposable PostgreSQL | Real Cot migrations, scoped authorization, concurrent edits, atomic audit/history/outbox, approval revalidation | +| Instance access, database | `tests/access.rs` | PostgreSQL with CREATEDB | Configuration sync (create, adopt, rename, disable, re-enable, safety refusals, ambiguity), group-derived roles and revocation, instance administrators acting without membership, employee permission set, bootstrap accounts across hours and concurrent replicas, administrator operations and workspace deletion, SSO account creation/revocation and email contact linking | +| SSO over HTTP | `tests/oidc.rs`, `tests/support/fake_oidc.rs` | PostgreSQL with CREATEDB + loopback | The real application behind a fake OpenID provider: state cookie and one-time flow, PKCE, RS256 verification, rejected issuer/audience/expiry/nonce/signature, groups from ID token and userinfo, no-groups refusal without an account, session cookies, no registration endpoint | +| Demo lifecycle | `tests/demo_lifecycle.rs` | PostgreSQL with CREATEDB | Database protection and reset, demo configuration seeding with the six accounts, retention and reseeding (own binary because the demonstration notice is process-wide) | | HTTP provider contract | Integration suite + stateful local fake | PostgreSQL + loopback | Wire methods/payloads, partial 429 failure, checkpoint recovery, read-back, drift, external overrides, owned-only deletion | -| Browser/API | `tests/browser.cjs`, `tests/browser-calendars.cjs`, `tests/browser-configuration.cjs` | Seeded local app + Chromium | Forms, calendar, approval, workspace isolation, CSRF/origin, people/imports/timezones, removal dependencies, native routing drag/drop and position editing, desktop/mobile | +| Browser/API | `tests/browser*.cjs` (all listed in `npm run test:browser`) | Freshly started demo + Chromium | Forms, calendar, approval, workspace isolation, CSRF/origin, people/imports/timezones, removal dependencies, native routing drag/drop and position editing, desktop/mobile; `browser-access.cjs`: demo login guide, no registration, SSO error banner, instance page and read-only rules, employee permission editing, role-based views | | Live acceptance | Dedicated JSM test team | Test credentials | Actual provider timeline, subscription-specific escalations and routing; never ordinary CI | ## Invariants @@ -31,7 +35,7 @@ Scheduling errors are product failures. A change to ownership, time boundaries, ## Running and extending -`cargo test` runs pure tests and explicitly reports database tests as ignored. CI also runs `cargo test --test integration -- --ignored --test-threads=1` with PostgreSQL 17. Fixtures use UUID names and never truncate shared tables. Use a disposable database. Run this suite serially because worker tests inspect the global queue. Worker tests explicitly prioritize only their own job so a bounded batch cannot skip it because of jobs left by a prior run. +`cargo test` runs pure tests and explicitly reports database tests as ignored. With `MEGURI_TEST_DATABASE_URL` pointing at a disposable PostgreSQL 17 database whose user has CREATEDB, CI and developers run everything with `cargo test --locked --tests -- --ignored --test-threads=1`. Scheduling fixtures create uniquely keyed workspaces and users through the real sync and group path and never truncate shared tables; tests whose subject is whole-instance state (sync, bootstrap, administration, SSO) use `IsolatedDatabase`, a uniquely named database dropped at the end, because sync disables every unconfigured workspace. Run serially because worker tests inspect the global queue. Worker tests explicitly prioritize only their own job so a bounded batch cannot skip it because of jobs left by a prior run. `tests/oidc.rs` installs process-wide configuration and therefore holds one test function. The fake models JSM responses and is not a substitute for live acceptance. New endpoints need verification against official documentation, a wire assertion, and a failure/recovery test. Do not make the fake accept an invented contract to get tests green. @@ -103,13 +107,13 @@ Binding selection uses one anchored, viewport-clamped popover beside the origina `browser-repeat-defaults.cjs` verifies absent escalation defaults render as editable numbers, edited values survive reopening the draft, and explicit dictionary lookups remain intact. -The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`. +The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also has Support/Commerce access through her groups. Other demo workspaces remain separate. The login password is the one generated for the run. Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration. Permanent assignees are tested as single-participant rotations, including absence, replacement approval and publication blocking. The removed fixed-assignee schedule and generation types are rejected rather than normalized. -Workspace isolation regressions exercise independent memberships and variables, a single revision for configuration and working-time changes, read-only generation preview, stale review rejection, atomic activation/history/outbox, and owner settings permissions. +Workspace isolation regressions exercise independent memberships and variables, a single revision for configuration and working-time changes, read-only generation preview, stale review rejection, atomic activation/history/outbox, and workspace-administrator settings permissions. The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation. @@ -124,3 +128,9 @@ Map input details display evaluated server values for each block and pass, never The map’s all-passes input overview lists deduplicated upstream sources, not expanded output settings: variables with used binding addresses and resource references grouped by producing block. References internal to the selected whole rule are omitted; selecting an individual output block shows its dependencies. Selecting a concrete pass still exposes its evaluated field values. Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. + +## Access and identity regressions + +Authorization boundaries are product failures like scheduling ones. A change to configuration sync, group mapping, roles, SSO, bootstrap or administration needs a regression in `tests/access.rs`, `tests/oidc.rs` or the pure tests. Existing cases pin: exact (not prefix/case-insensitive) group matching; membership removal when a group or workspace disappears, including for open sessions; instance administrators acting on workspaces without membership and being audited under their own ID; employees unable to administer integrations or members; the employee permission set applying to existing and future employees at once, with `view` mandatory and administrator-only permissions refused; refusal of a configuration that would disable everything and of ambiguous adoption before any write; bootstrap passwords equal for replicas in one UTC hour, rotated (ending sessions) in the next, and locked out when the block is removed; no SSO account takeover by a bootstrap name; SSO accounts created only when groups map, revoked at the next sign-in, never given a password; administrators unable to edit their own groups or SSO groups; deletion only of disabled, unconfigured workspaces with the typed key; contact linking rewriting the current document while history keeps the old ID and audit still resolves it, skipping ambiguous or conflicting data and never granting access. + +The browser access suite also runs against the real demo. No test may weaken these checks to make a failing sign-in pass; fix the cause or record the limitation. diff --git a/package.json b/package.json index b496bb3..1b8b22a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "meguri-browser-tests", "private": true, "scripts": { - "test:browser": "node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs" + "test:browser": "node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs && node tests/browser-access.cjs" }, "devDependencies": { "playwright": "1.62.1" diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 index cbfd6e0..7bb3028 100644 --- a/scripts/dev.ps1 +++ b/scripts/dev.ps1 @@ -40,10 +40,10 @@ Push-Location $repo try { cargo build --locked; if ($LASTEXITCODE) { throw 'Build failed' } } finally { Pop-Location } $env:MEGURI_BIND = "127.0.0.1:$Port" $env:MEGURI_PUBLIC_URL = "http://127.0.0.1:$Port" -$env:MEGURI_ALLOW_SIGNUP = 'true' $env:MEGURI_SEED_DEMO = 'true' $env:MEGURI_ROLE = 'all' Copy-Item -LiteralPath (Join-Path $repo 'target/debug/meguri.exe') -Destination $appExecutable $app = Start-Process -FilePath $appExecutable -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $localDir 'app.log') -RedirectStandardError (Join-Path $localDir 'app-error.log') $app.Id | Set-Content $pidFile -Write-Output "Meguri starting at $env:MEGURI_PUBLIC_URL — demo: alice / meguri-demo-only" +Write-Output "Meguri starting at $env:MEGURI_PUBLIC_URL as a disposable demo. Accounts: admin (instance administrator), alice (workspace administrator), bob, carol, dave, erin (employees)." +Write-Output "One password is generated on every start: it is shown on the login screen and logged in $(Join-Path $localDir 'app.log')." diff --git a/src/access.rs b/src/access.rs new file mode 100644 index 0000000..95343fc --- /dev/null +++ b/src/access.rs @@ -0,0 +1,535 @@ +//! Pure instance access model: declarative configuration, group-to-role resolution and +//! bootstrap passwords. No HTTP or database dependencies. +use crate::error::{Error, Result}; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::Sha256; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] + pub auth: AuthConfig, + #[serde(default)] + pub oidc: Option, + #[serde(default)] + pub instance_admin_groups: Vec, + #[serde(default)] + pub workspaces: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AuthConfig { + /// Allows every password account to sign in. Bootstrap accounts always may. + #[serde(default)] + pub password_login: bool, + /// Break-glass instance administrators with generated passwords. Omit in production. + #[serde(default)] + pub bootstrap_admin: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BootstrapConfig { + pub usernames: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GroupSource { + IdToken, + Userinfo, +} + +fn default_scopes() -> Vec { + vec!["openid".into(), "profile".into(), "email".into()] +} +fn default_groups_claim() -> String { + "groups".into() +} +fn default_group_sources() -> Vec { + vec![GroupSource::IdToken, GroupSource::Userinfo] +} + +/// The client secret is a deployment secret (`MEGURI_OIDC_CLIENT_SECRET`), never configuration. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct OidcConfig { + pub issuer: String, + pub client_id: String, + #[serde(default = "default_scopes")] + pub scopes: Vec, + /// Dotted path into the token claims, for example `groups` or `realm_access.roles`. + #[serde(default = "default_groups_claim")] + pub groups_claim: String, + #[serde(default = "default_group_sources")] + pub groups_sources: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfig { + pub key: String, + pub name: String, + #[serde(default)] + pub admin_groups: Vec, + #[serde(default)] + pub member_groups: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + Employee, + Admin, +} + +/// What a set of groups grants. Administrators win over employees in the same workspace. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Access { + pub instance_admin: bool, + pub workspaces: BTreeMap, +} +impl Access { + pub fn is_empty(&self) -> bool { + !self.instance_admin && self.workspaces.is_empty() + } +} + +fn invalid(message: impl Into) -> Error { + Error::Validation(format!("Invalid configuration: {}", message.into())) +} + +pub fn valid_username(username: &str) -> bool { + (3..=64).contains(&username.len()) + && username + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c)) +} + +fn valid_key(key: &str) -> bool { + (3..=64).contains(&key.len()) + && key + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !key.starts_with('-') + && !key.ends_with('-') +} + +fn validate_groups(owner: &str, groups: &[String]) -> Result<()> { + let mut seen = BTreeSet::new(); + for group in groups { + if group.trim().is_empty() || group.len() > 256 || group.chars().any(char::is_control) { + return Err(invalid(format!( + "{owner} has an empty, oversized or unprintable group name" + ))); + } + if !seen.insert(group) { + return Err(invalid(format!("{owner} lists group {group:?} twice"))); + } + } + Ok(()) +} + +impl Config { + pub fn parse(text: &str) -> Result { + let config: Self = + serde_yaml_ng::from_str(text).map_err(|e| invalid(format!("cannot parse: {e}")))?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<()> { + if let Some(oidc) = &self.oidc { + let issuer = reqwest::Url::parse(&oidc.issuer) + .map_err(|_| invalid("oidc.issuer must be an absolute URL"))?; + let loopback = matches!(issuer.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")); + if issuer.scheme() != "https" && !(issuer.scheme() == "http" && loopback) { + return Err(invalid("oidc.issuer must use HTTPS outside localhost")); + } + if oidc.client_id.trim().is_empty() { + return Err(invalid("oidc.client_id is required")); + } + if !oidc.scopes.iter().any(|s| s == "openid") { + return Err(invalid("oidc.scopes must include openid")); + } + if oidc.groups_claim.trim().is_empty() { + return Err(invalid("oidc.groups_claim is required")); + } + if oidc.groups_sources.is_empty() { + return Err(invalid("oidc.groups_sources needs at least one source")); + } + } + if let Some(bootstrap) = &self.auth.bootstrap_admin { + if bootstrap.usernames.is_empty() { + return Err(invalid( + "auth.bootstrap_admin.usernames must not be empty; remove the block instead", + )); + } + let mut seen = BTreeSet::new(); + for name in &bootstrap.usernames { + if !valid_username(name) || !seen.insert(name) { + return Err(invalid(format!( + "bootstrap username {name:?} must be unique and use 3-64 lowercase letters, numbers, dots, underscores or hyphens" + ))); + } + } + } + if self.oidc.is_none() && !self.auth.password_login && self.auth.bootstrap_admin.is_none() { + return Err(invalid( + "configure at least one way to sign in: oidc, auth.password_login or auth.bootstrap_admin", + )); + } + validate_groups("instance_admin_groups", &self.instance_admin_groups)?; + let mut keys = BTreeSet::new(); + let mut names = BTreeSet::new(); + for workspace in &self.workspaces { + if !valid_key(&workspace.key) { + return Err(invalid(format!( + "workspace key {:?} must use 3-64 lowercase letters, numbers and hyphens, without leading/trailing hyphen", + workspace.key + ))); + } + if !keys.insert(workspace.key.as_str()) { + return Err(invalid(format!( + "workspace key {:?} is used twice", + workspace.key + ))); + } + if crate::domain::validate_name(&workspace.name).is_err() { + return Err(invalid(format!( + "workspace {:?} needs a name of 1-120 characters", + workspace.key + ))); + } + if !names.insert(workspace.name.trim().to_lowercase()) { + return Err(invalid(format!( + "workspace name {:?} is used twice", + workspace.name + ))); + } + if workspace.admin_groups.is_empty() { + return Err(invalid(format!( + "workspace {:?} needs at least one admin group", + workspace.key + ))); + } + validate_groups( + &format!("workspace {:?} admin_groups", workspace.key), + &workspace.admin_groups, + )?; + validate_groups( + &format!("workspace {:?} member_groups", workspace.key), + &workspace.member_groups, + )?; + } + Ok(()) + } + + /// Every group that grants something; password-user assignments are chosen from this list. + pub fn known_groups(&self) -> Vec { + let mut groups: BTreeSet<&String> = self.instance_admin_groups.iter().collect(); + for workspace in &self.workspaces { + groups.extend(&workspace.admin_groups); + groups.extend(&workspace.member_groups); + } + groups.into_iter().cloned().collect() + } + + pub fn password_login_enabled(&self) -> bool { + self.auth.password_login + } + + pub fn bootstrap_usernames(&self) -> &[String] { + self.auth + .bootstrap_admin + .as_ref() + .map_or(&[], |b| b.usernames.as_slice()) + } + + pub fn password_form_available(&self) -> bool { + self.auth.password_login || !self.bootstrap_usernames().is_empty() + } +} + +static CURRENT: std::sync::OnceLock = std::sync::OnceLock::new(); +/// Installs the process-wide configuration read by request handlers. +pub fn install(config: Config) { + let _ = CURRENT.set(config); +} +pub fn current() -> Result<&'static Config> { + CURRENT + .get() + .ok_or_else(|| Error::Internal("the instance configuration is not loaded".into())) +} + +/// Exact string matching of provider groups against the configured groups. +pub fn resolve_access(config: &Config, groups: &[String], bootstrap: bool) -> Access { + let held: BTreeSet<&str> = groups.iter().map(String::as_str).collect(); + let any = |wanted: &[String]| wanted.iter().any(|g| held.contains(g.as_str())); + let mut access = Access { + instance_admin: bootstrap || any(&config.instance_admin_groups), + workspaces: BTreeMap::new(), + }; + for workspace in &config.workspaces { + let role = if any(&workspace.admin_groups) { + Some(Role::Admin) + } else if any(&workspace.member_groups) { + Some(Role::Employee) + } else { + None + }; + if let Some(role) = role { + access.workspaces.insert(workspace.key.clone(), role); + } + } + access +} + +/// Reads a group list from a claims object. A literal key wins over a dotted path. +/// `None` means the claim is missing or has an unusable type, which is a provider +/// misconfiguration, not an empty membership. +pub fn claim_groups(claims: &Value, path: &str) -> Option> { + let value = claims.get(path).or_else(|| { + path.split('.') + .try_fold(claims, |value, part| value.get(part)) + })?; + match value { + Value::String(group) => Some(vec![group.clone()]), + Value::Array(items) => Some( + items + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(), + ), + _ => None, + } +} + +const BASE32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; +fn base32(bytes: &[u8]) -> String { + let mut output = String::new(); + let (mut buffer, mut bits) = (0u32, 0u32); + for byte in bytes { + buffer = (buffer << 8) | u32::from(*byte); + bits += 8; + while bits >= 5 { + bits -= 5; + output.push(BASE32[((buffer >> bits) & 31) as usize] as char); + } + } + if bits > 0 { + output.push(BASE32[((buffer << (5 - bits)) & 31) as usize] as char); + } + output +} + +/// Replicas that start in the same UTC hour derive the same password, and it cannot be +/// guessed without the secret stored in the database. +pub fn bootstrap_password(secret: &[u8], username: &str, now: DateTime) -> String { + let mut mac = as Mac>::new_from_slice(secret).expect("HMAC accepts any key"); + mac.update(format!("meguri-bootstrap:{username}:{}", now.format("%Y%m%d%H")).as_bytes()); + base32(&mac.finalize().into_bytes()[..20]) +} + +/// A fresh high-entropy password for single-process demonstration runs. +pub fn random_password() -> String { + let mut bytes = [0u8; 20]; + bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(&uuid::Uuid::new_v4().as_bytes()[..4]); + base32(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn config() -> Config { + Config::parse( + r#" +auth: {password_login: true} +instance_admin_groups: ["/meguri/admins"] +workspaces: + - {key: payments, name: Payments, admin_groups: ["/payments/leads"], member_groups: ["/payments/team", "/everyone"]} + - {key: search, name: Search, admin_groups: ["/search/leads", "/everyone"], member_groups: ["/search/team"]} +"#, + ) + .unwrap() + } + fn groups(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn groups_map_to_roles_by_exact_match_and_admin_wins() { + let c = config(); + let access = resolve_access(&c, &groups(&["/payments/team", "/payments/leads"]), false); + assert_eq!(access.workspaces["payments"], Role::Admin); + assert!(!access.workspaces.contains_key("search")); + assert!(!access.instance_admin); + + let access = resolve_access(&c, &groups(&["/everyone"]), false); + assert_eq!(access.workspaces["payments"], Role::Employee); + assert_eq!(access.workspaces["search"], Role::Admin); + + for near_miss in [ + "/payments/team/", + "payments/team", + "/Payments/Team", + "/payments", + ] { + assert!(resolve_access(&c, &groups(&[near_miss]), false).is_empty()); + } + assert!(resolve_access(&c, &[], false).is_empty()); + } + + #[test] + fn instance_admin_comes_from_a_group_or_the_bootstrap_flag_without_workspace_access() { + let c = config(); + let access = resolve_access(&c, &groups(&["/meguri/admins"]), false); + assert!(access.instance_admin && access.workspaces.is_empty()); + assert!(!access.is_empty()); + assert!(resolve_access(&c, &[], true).instance_admin); + } + + #[test] + fn claims_support_literal_and_dotted_paths_and_report_missing_claims() { + let claims = serde_json::json!({ + "groups": ["/a", "/b", 7], + "realm_access": {"roles": ["admin"]}, + "https://example.com/groups": "/single", + "bad": {"x": 1} + }); + assert_eq!(claim_groups(&claims, "groups"), Some(groups(&["/a", "/b"]))); + assert_eq!( + claim_groups(&claims, "realm_access.roles"), + Some(groups(&["admin"])) + ); + assert_eq!( + claim_groups(&claims, "https://example.com/groups"), + Some(groups(&["/single"])) + ); + assert_eq!(claim_groups(&claims, "missing"), None); + assert_eq!(claim_groups(&claims, "bad"), None); + assert_eq!( + claim_groups(&serde_json::json!({"groups": []}), "groups"), + Some(vec![]) + ); + } + + #[test] + fn bootstrap_passwords_are_stable_within_an_hour_and_depend_on_secret_user_and_hour() { + let hour = Utc.with_ymd_and_hms(2026, 10, 7, 14, 5, 0).unwrap(); + let same_hour = Utc.with_ymd_and_hms(2026, 10, 7, 14, 59, 59).unwrap(); + let next_hour = Utc.with_ymd_and_hms(2026, 10, 7, 15, 0, 0).unwrap(); + let password = bootstrap_password(b"secret-one", "admin", hour); + assert_eq!( + password, + bootstrap_password(b"secret-one", "admin", same_hour) + ); + assert_ne!( + password, + bootstrap_password(b"secret-one", "admin", next_hour) + ); + assert_ne!(password, bootstrap_password(b"secret-two", "admin", hour)); + assert_ne!(password, bootstrap_password(b"secret-one", "other", hour)); + assert!(password.len() >= 24 && password.chars().all(|c| c.is_ascii_alphanumeric())); + assert_ne!(random_password(), random_password()); + } + + #[test] + fn configuration_is_validated_strictly() { + let ok = "auth: {password_login: true}\nworkspaces: []\n"; + assert!(Config::parse(ok).is_ok()); + for (bad, why) in [ + ("workspaces: []\n", "no sign-in method"), + ("auth: {password_login: true, typo: 1}\n", "unknown field"), + ( + "auth: {password_login: true}\nworkspaces: [{key: Bad_Key, name: A, admin_groups: [g]}]\n", + "key format", + ), + ( + "auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g]}, {key: aaa, name: B, admin_groups: [g]}]\n", + "duplicate key", + ), + ( + "auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g]}, {key: bbb, name: a, admin_groups: [g]}]\n", + "duplicate name", + ), + ( + "auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: []}]\n", + "no admin group", + ), + ( + "auth: {password_login: true}\nworkspaces: [{key: aaa, name: A, admin_groups: [g, g]}]\n", + "duplicate group", + ), + ( + "auth: {bootstrap_admin: {usernames: []}}\n", + "empty bootstrap list", + ), + ( + "auth: {bootstrap_admin: {usernames: [Admin]}}\n", + "bad bootstrap name", + ), + ( + "oidc: {issuer: 'http://idp.example.com', client_id: x}\n", + "plain HTTP issuer", + ), + ( + "oidc: {issuer: 'https://idp.example.com', client_id: x, scopes: [email]}\n", + "scope without openid", + ), + ("{", "syntax"), + ] { + assert!(Config::parse(bad).is_err(), "must reject: {why}"); + } + let oidc = + Config::parse("oidc: {issuer: 'http://127.0.0.1:9000/realms/x', client_id: meguri}\n") + .unwrap(); + let oidc = oidc.oidc.unwrap(); + assert_eq!(oidc.groups_claim, "groups"); + assert_eq!(oidc.scopes, ["openid", "profile", "email"]); + assert_eq!( + oidc.groups_sources, + [GroupSource::IdToken, GroupSource::Userinfo] + ); + } + + #[test] + fn the_shipped_example_configuration_is_valid() { + let config = Config::parse(include_str!("../deploy/instance-config.example.yaml")).unwrap(); + assert!(config.oidc.is_some() && !config.workspaces.is_empty()); + assert!(config.bootstrap_usernames().is_empty()); + } + + #[test] + fn known_groups_are_unique_and_sorted_and_the_form_flags_follow_the_configuration() { + let c = config(); + assert_eq!( + c.known_groups(), + groups(&[ + "/everyone", + "/meguri/admins", + "/payments/leads", + "/payments/team", + "/search/leads", + "/search/team" + ]) + ); + assert!(c.password_form_available()); + let sso_only = + Config::parse("oidc: {issuer: 'https://idp.example.com', client_id: x}\n").unwrap(); + assert!(!sso_only.password_form_available()); + let bootstrap = Config::parse( + "oidc: {issuer: 'https://idp.example.com', client_id: x}\nauth: {bootstrap_admin: {usernames: [admin]}}\n", + ) + .unwrap(); + assert!(bootstrap.password_form_available() && !bootstrap.password_login_enabled()); + } +} diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..c65efb3 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,315 @@ +//! Instance administration: read-only workspace overview, password-user management and the +//! deliberate deletion of workspaces that configuration disabled. +use crate::{ + access::{Config, valid_username}, + domain::validate_name, + error::{Error, Result, ensure}, + identity::{apply_access, new_user_row, normalize_email}, + models::*, + service, + store::{self, Store}, +}; +use cot::db::{Model, query}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use uuid::Uuid; + +pub async fn require_instance_admin(db: &Store, actor: Uuid) -> Result<()> { + if db.is_instance_admin(actor).await? { + Ok(()) + } else { + Err(Error::Forbidden) + } +} + +async fn sso_user_ids(db: &Store) -> Result> { + Ok(ExternalIdentityRow::objects() + .all(&db.db) + .await? + .into_iter() + .map(|i| i.user_id) + .collect()) +} + +/// Everything the instance administrator may see. Workspaces and group mappings are read-only +/// here because configuration owns them. +pub async fn overview(db: &Store, config: &Config, actor: Uuid) -> Result { + require_instance_admin(db, actor).await?; + let mut members: BTreeMap = BTreeMap::new(); + for member in WorkspaceMemberRow::objects().all(&db.db).await? { + *members.entry(member.workspace_id).or_default() += 1; + } + let mut jobs: BTreeMap = BTreeMap::new(); + for job in SyncJobRow::objects().all(&db.db).await? { + jobs.insert(job.workspace_id.clone(), job); + } + let mut workspaces = vec![]; + for row in WorkspaceRow::objects().all(&db.db).await? { + if row.demo_deleted_at.is_some() { + continue; + } + let name = serde_json::from_str::(&row.document)?.name; + let configured = row + .key + .as_ref() + .and_then(|key| config.workspaces.iter().find(|w| &w.key == key)); + let job = jobs.get(&row.id); + workspaces.push(json!({ + "id":row.id, + "key":row.key, + "name":name, + "created_at":row.created_at, + "disabled_at":row.disabled_at, + "in_config":configured.is_some(), + "admin_groups":configured.map(|w| w.admin_groups.clone()).unwrap_or_default(), + "member_groups":configured.map(|w| w.member_groups.clone()).unwrap_or_default(), + "member_count":members.get(&row.id).copied().unwrap_or(0), + "sync_state":job.map(|j| j.state.clone()), + "last_success":job.and_then(|j| j.last_success), + "confirm":row.key.clone().unwrap_or_else(|| name.clone()), + })); + } + workspaces.sort_by(|a, b| { + (a["disabled_at"].is_null(), a["name"].as_str()) + .cmp(&(b["disabled_at"].is_null(), b["name"].as_str())) + }); + let sso = sso_user_ids(db).await?; + let mut users = vec![]; + let mut rows = UserRow::objects().all(&db.db).await?; + rows.sort_by(|a, b| a.username.cmp(&b.username)); + for row in rows { + if row.demo_deleted_at.is_some() { + continue; + } + users.push(json!({ + "id":row.id, + "username":row.username, + "display_name":row.display_name, + "email":row.email, + "sso":sso.contains(&row.id), + "bootstrap":row.bootstrap, + "instance_admin":row.instance_admin, + "groups":crate::identity::stored_groups(&row)?, + "created_at":row.created_at, + })); + } + Ok(json!({ + "config":{ + "password_login":config.password_login_enabled(), + "bootstrap_usernames":config.bootstrap_usernames(), + "oidc_issuer":config.oidc.as_ref().map(|o| o.issuer.clone()), + "instance_admin_groups":config.instance_admin_groups, + }, + "workspaces":workspaces, + "users":users, + "known_groups":config.known_groups(), + })) +} + +fn checked_groups(config: &Config, mut groups: Vec) -> Result> { + groups.sort(); + groups.dedup(); + let known: BTreeSet = config.known_groups().into_iter().collect(); + ensure( + groups.iter().all(|g| known.contains(g)), + crate::i18n::text("err_choose_groups_from_the_configured_list_5d0e71"), + )?; + Ok(groups) +} + +#[derive(Debug, Deserialize)] +pub struct NewUser { + pub username: String, + pub display_name: String, + pub password: String, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub groups: Vec, +} + +/// Creates a password account. `actor` is None only for trusted startup seeding; the request +/// handler always goes through [`create_user`]. +pub async fn create_password_user( + db: &Store, + config: &Config, + actor: Option, + input: NewUser, +) -> Result { + let username = input.username.trim().to_ascii_lowercase(); + ensure( + valid_username(&username), + crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"), + )?; + validate_name(&input.display_name)?; + let email = match input + .email + .as_deref() + .map(str::trim) + .filter(|e| !e.is_empty()) + { + None => None, + Some(email) => Some(normalize_email(email).ok_or_else(|| { + Error::Validation(crate::i18n::text("err_enter_a_valid_email_address_1577ea").into()) + })?), + }; + let groups = checked_groups(config, input.groups)?; + let hash = service::hash_password(input.password).await?; + let mut row = new_user_row(username.clone(), input.display_name, Some(hash)); + row.email = email; + match row.clone().insert(&db.db).await { + Ok(_) => {} + Err(cot::db::DatabaseError::UniqueViolation) => { + return Err(Error::Conflict( + crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(), + )); + } + Err(e) => return Err(e.into()), + } + let id = store::uuid(&row.id)?; + apply_access(db, config, id, Some(groups.clone())).await?; + db.instance_event( + actor, + "user.created", + json!({"user_id":id,"username":username,"groups":groups}), + ) + .await?; + let created = query!(UserRow,$id==row.id.clone()) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + Ok(json!({"user":store::user(created)?})) +} + +pub async fn create_user( + db: &Store, + config: &Config, + actor: Uuid, + input: NewUser, +) -> Result { + require_instance_admin(db, actor).await?; + create_password_user(db, config, Some(actor), input).await +} + +/// Password accounts only: SSO groups come from the provider and bootstrap accounts from +/// configuration. Administrators cannot edit their own groups, so the last one cannot vanish. +async fn editable_target(db: &Store, actor: Uuid, target: Uuid) -> Result { + ensure( + actor != target, + crate::i18n::text("err_you_cannot_change_your_own_access_9b44c0"), + )?; + let id = target.to_string(); + let row = query!(UserRow,$id==id) + .get(&db.db) + .await? + .filter(|r| r.demo_deleted_at.is_none()) + .ok_or(Error::NotFound)?; + ensure( + !row.bootstrap && !sso_user_ids(db).await?.contains(&row.id), + crate::i18n::text("err_this_account_is_managed_by_sso_or_configuration_a3c8e2"), + )?; + Ok(row) +} + +pub async fn set_user_groups( + db: &Store, + config: &Config, + actor: Uuid, + target: Uuid, + groups: Vec, +) -> Result { + require_instance_admin(db, actor).await?; + let before = editable_target(db, actor, target).await?; + let groups = checked_groups(config, groups)?; + apply_access(db, config, target, Some(groups.clone())).await?; + db.instance_event( + Some(actor), + "user.groups_changed", + json!({"user_id":target,"username":before.username,"before":crate::identity::stored_groups(&before)?,"after":groups}), + ) + .await?; + let id = target.to_string(); + let row = query!(UserRow,$id==id) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + Ok(json!({"user":store::user(row)?})) +} + +pub async fn reset_password(db: &Store, actor: Uuid, target: Uuid, password: String) -> Result<()> { + require_instance_admin(db, actor).await?; + let row = editable_target(db, actor, target).await?; + let hash = service::hash_password(password).await?; + db.db + .raw_with( + "UPDATE meguri__users SET password_hash=$2 WHERE id=$1", + &[&row.id, &hash], + ) + .await?; + db.db + .raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id]) + .await?; + db.instance_event( + Some(actor), + "user.password_reset", + json!({"user_id":target,"username":row.username}), + ) + .await +} + +const DELETE_WORKSPACE: &str = r#" +WITH target AS MATERIALIZED ( + SELECT id FROM meguri__workspaces w WHERE w.id=$1 AND w.disabled_at IS NOT NULL AND w.demo_deleted_at IS NULL + AND NOT EXISTS(SELECT 1 FROM meguri__sync_jobs j WHERE j.workspace_id=w.id AND j.lease_until>now()) +), members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM target)), +history AS (DELETE FROM meguri__workspace_history WHERE workspace_id IN(SELECT id FROM target)), +audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM target)), +jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM target)), +objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM target)), +pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM target)) +DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM target) +"#; + +/// Permanently removes a workspace that is disabled and absent from configuration. The caller +/// must type its key (or name when it has none). Remote provider resources are not touched. +pub async fn delete_workspace( + db: &Store, + config: &Config, + actor: Uuid, + workspace: Uuid, + confirm: &str, +) -> Result<()> { + require_instance_admin(db, actor).await?; + let id = workspace.to_string(); + let row = query!(WorkspaceRow,$id==id) + .get(&db.db) + .await? + .filter(|r| r.demo_deleted_at.is_none()) + .ok_or(Error::NotFound)?; + let name = serde_json::from_str::(&row.document)?.name; + ensure( + row.disabled_at.is_some() + && !row + .key + .as_ref() + .is_some_and(|key| config.workspaces.iter().any(|w| &w.key == key)), + crate::i18n::text("err_only_disabled_workspaces_can_be_deleted_8f27d1"), + )?; + let expected = row.key.clone().unwrap_or_else(|| name.clone()); + ensure( + confirm == expected, + crate::i18n::text("err_type_the_workspace_key_to_confirm_deletion_4be9a0"), + )?; + let result = db.db.raw_with(DELETE_WORKSPACE, &[&row.id]).await?; + ensure( + result.rows_affected().0 > 0, + crate::i18n::text("err_the_workspace_is_publishing_try_again_shortly_2a7d94"), + )?; + db.instance_event( + Some(actor), + "workspace.deleted", + json!({"id":row.id,"key":row.key,"name":name}), + ) + .await +} diff --git a/src/audit.rs b/src/audit.rs index 73d1de2..3f02d48 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -8,6 +8,9 @@ pub type Names = BTreeMap; pub fn remember(names: &mut Names, doc: &WorkspaceDocument) { for person in &doc.people { names.insert(person.id.to_string(), person.display_name.clone()); + for alias in &person.aliases { + names.insert(alias.to_string(), person.display_name.clone()); + } if let Some(id) = person.user_id { names.insert(id.to_string(), person.display_name.clone()); } @@ -87,4 +90,22 @@ mod tests { assert_eq!(resolved["unknown_id"], "missing"); assert_eq!(raw["from_user"], id); } + + #[test] + fn merged_contact_ids_keep_resolving_to_the_person() { + let (old, new) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + let mut doc = WorkspaceDocument::new("Team".into()); + doc.people.push(crate::domain::Person { + id: new, + user_id: Some(new), + display_name: "Dana".into(), + email: Some("dana@example.com".into()), + country: None, + aliases: vec![old], + }); + let mut names = Names::new(); + remember(&mut names, &doc); + assert_eq!(names[&old.to_string()], "Dana"); + assert_eq!(names[&new.to_string()], "Dana"); + } } diff --git a/src/demo-config.yaml b/src/demo-config.yaml new file mode 100644 index 0000000..2fbac55 --- /dev/null +++ b/src/demo-config.yaml @@ -0,0 +1,24 @@ +# Demonstration instance. It is described exactly like a production configuration; only the +# bootstrap administrator and the password sign-in switch differ. +auth: + password_login: true + bootstrap_admin: + usernames: [admin] +instance_admin_groups: [demo-instance-admins] +workspaces: + - key: acme-engineering + name: Acme Engineering + admin_groups: [acme-leads] + member_groups: [acme-staff] + - key: customer-support + name: Customer Support + admin_groups: [support-leads] + member_groups: [support-staff] + - key: regional-platform + name: Regional Platform + admin_groups: [regional-leads] + member_groups: [regional-staff] + - key: commerce-operations + name: Commerce Operations + admin_groups: [commerce-leads] + member_groups: [commerce-staff] diff --git a/src/demo.rs b/src/demo.rs index 86c9773..eb4eaa4 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -4,12 +4,13 @@ use crate::{ error::Result, generation::{Node, Rule, Variable}, generation_service::{self, Draft}, - models::{UserRow, WorkspaceRow}, + identity, + models::UserRow, service, store::{self, Store}, }; use chrono::{Datelike, Duration, NaiveDate, Utc}; -use cot::db::{Model, query}; +use cot::db::query; use serde_json::{Value, json}; use uuid::Uuid; @@ -226,148 +227,250 @@ pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec Result<()> { - let mut users = vec![]; - for (username, display) in [ - ("alice", "Alice Morgan"), - ("bob", "Bob Chen"), - ("carol", "Carol Davis"), - ("dave", "Dave Wilson"), - ("erin", "Erin Patel"), - ] { - let user = match query!(UserRow,$username==username).get(&db.db).await? { - Some(row) => store::user(row)?, - None => { - service::register( - db, - username.into(), - display.into(), - "meguri-demo-only".into(), - ) - .await? - } - }; - users.push(user); +/// Demonstration accounts: groups assigned exactly as an instance administrator would. +const ACCOUNTS: [(&str, &str, &str, &[&str]); 6] = [ + ("admin", "Instance Admin", "instance_admin", &[]), + ( + "alice", + "Alice Morgan", + "workspace_admin", + &[ + "acme-leads", + "support-leads", + "regional-leads", + "commerce-leads", + ], + ), + ( + "bob", + "Bob Chen", + "employee", + &["acme-staff", "regional-staff"], + ), + ( + "carol", + "Carol Davis", + "employee", + &["acme-staff", "support-staff", "commerce-staff"], + ), + ("dave", "Dave Wilson", "employee", &["acme-staff"]), + ("erin", "Erin Patel", "employee", &["acme-staff"]), +]; + +/// `MEGURI_DEMO_SANDBOXES=n` (0-20) adds empty "Sandbox 1..n" workspaces, each with its own +/// administrator group, so automated browser checks can mutate state without touching the +/// examples. Normal demonstrations do not set it. +fn sandboxes() -> usize { + std::env::var("MEGURI_DEMO_SANDBOXES") + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(0, |n| n.min(20)) +} + +pub fn config() -> Result { + let mut config = crate::access::Config::parse(include_str!("demo-config.yaml"))?; + for number in 1..=sandboxes() { + config.workspaces.push(crate::access::WorkspaceConfig { + key: format!("sandbox-{number}"), + name: format!("Sandbox {number}"), + admin_groups: vec![format!("sandbox-{number}-leads")], + member_groups: vec![format!("sandbox-{number}-staff")], + }); } - for user in &users { + config.validate()?; + Ok(config) +} + +/// Accounts shown on the demonstration login screen. +pub fn accounts() -> Vec { + ACCOUNTS + .iter() + .map(|(username, _, role, _)| crate::demo_runtime::DemoAccount { + username: (*username).into(), + role: (*role).into(), + }) + .collect() +} + +async fn user_id(db: &Store, username: &str) -> Result { + let name = username.to_owned(); + let row = query!(UserRow,$username==name) + .get(&db.db) + .await? + .ok_or(crate::error::Error::NotFound)?; + store::uuid(&row.id) +} + +pub async fn seed(db: &Store) -> Result<()> { + let notice = crate::demo_runtime::notice() + .ok_or_else(|| crate::error::Error::Internal("demo mode is not active".into()))?; + let config = config()?; + identity::sync_config( + db, + &config, + identity::SyncOptions { + passwords: identity::BootstrapPasswords::Fixed(notice.password.clone()), + now: Utc::now(), + allow_disable_all: false, + }, + ) + .await?; + for (username, display, _, groups) in ACCOUNTS { + let name = username.to_owned(); + if query!(UserRow,$username==name).exists(&db.db).await? { + continue; + } + crate::admin::create_password_user( + db, + &config, + None, + crate::admin::NewUser { + username: username.into(), + display_name: display.into(), + password: notice.password.clone(), + email: None, + groups: groups.iter().map(|g| (*g).to_owned()).collect(), + }, + ) + .await?; + } + let mut users = vec![]; + for (username, ..) in ACCOUNTS { + let id = user_id(db, username).await?; db.db .raw_with( "INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING", - &[&user.id.to_string()], + &[&id.to_string()], ) .await?; + users.push(id); } - let alice = users[0].id; + // users[0] is the instance administrator; the five scheduling accounts follow. + let people: [Uuid; 5] = std::array::from_fn(|index| users[index + 1]); + let alice = people[0]; let today = Utc::now().date_naive(); let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday())); - for (index, workspace_name, timezone, start, end) in [ + for (index, key, timezone, start, end) in [ ( 1, - "Acme Engineering", + "acme-engineering", "Asia/Nicosia", "11:00:00", "19:00:00", ), ( 2, - "Customer Support", + "customer-support", "Europe/London", "09:00:00", "17:00:00", ), ( 3, - "Regional Platform", + "regional-platform", "Asia/Nicosia", "09:00:00", "18:00:00", ), ( 4, - "Commerce Operations", + "commerce-operations", "Europe/London", "09:00:00", "17:00:00", ), ] { - let mut existing = None; - for row in WorkspaceRow::objects().all(&db.db).await? { - if row.demo_deleted_at.is_some() { - continue; - } - let candidate = store::record(row)?; - if candidate.document.name == workspace_name - && db.owner(candidate.id, alice).await.is_ok() - { - existing = Some(candidate); - break; - } - } - if existing.is_some() { + let Some(workspace) = identity::find_workspace(db, key).await? else { continue; - } - let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?; - service::set_working_interval( - db, - alice, - workspace.id, - workspace.revision, - WorkingWindow { - timezone: timezone.into(), - weekdays: vec![1, 2, 3, 4, 5], - start: start.parse().unwrap(), - end: end.parse().unwrap(), - }, - ) - .await?; - workspace = db.workspace(workspace.id).await?; - let member_indices = if index == 1 { - vec![1, 2, 3, 4] - } else { - vec![if index % 2 == 1 { 1 } else { 2 }] }; - for member_index in member_indices { - workspace = service::set_member( - db, - alice, - workspace.id, - workspace.revision, - users[member_index].username.clone(), - Permission::member(), - ) - .await?; - } - // Fixed, documented 2026 examples; use the normal scoped CSV import path. - let csv = include_str!("demo-holidays-2026.csv"); - if !crate::holidays::compare(csv, &workspace.document.holidays)? - .additions - .is_empty() - { - workspace = - service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?; - } - let (variables, rules) = match index { - 1 => engineering_matrix(monday, std::array::from_fn(|index| users[index].id)), - 2 => support_queues(monday, alice, users[2].id), - 3 => regional_platform(monday, alice, users[1].id), - _ => service_tiers(monday, alice, users[2].id), - }; - let mut draft = Draft { - revision: workspace.revision, - variables, - rules, - route_position: 0, - review_id: None, - }; - let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?; - draft.review_id = report["review_id"].as_str().map(str::to_owned); - generation_service::apply(db, alice, workspace.id, draft).await?; - db.db + let claimed = db + .db .raw_with( "INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING", &[&workspace.id.to_string()], ) - .await?; + .await? + .rows_affected() + .0 + > 0; + if !claimed { + continue; + } + let result = seed_workspace( + db, + alice, + workspace, + index, + (timezone, start, end), + monday, + people, + ) + .await; + if let Err(error) = result { + // Release the claim so the next maintenance pass retries the example. + let id = identity::find_workspace(db, key) + .await? + .map(|w| w.id.to_string()); + db.db + .raw_with( + "DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id=$1", + &[&id], + ) + .await?; + return Err(error); + } } Ok(()) } + +async fn seed_workspace( + db: &Store, + alice: Uuid, + mut workspace: store::WorkspaceRecord, + index: usize, + (timezone, start, end): (&str, &str, &str), + monday: NaiveDate, + people: [Uuid; 5], +) -> Result<()> { + service::set_working_interval( + db, + alice, + workspace.id, + workspace.revision, + WorkingWindow { + timezone: timezone.into(), + weekdays: vec![1, 2, 3, 4, 5], + start: start.parse().unwrap(), + end: end.parse().unwrap(), + }, + ) + .await?; + workspace = db.workspace(workspace.id).await?; + // Fixed, documented 2026 examples; use the normal scoped CSV import path. + let csv = include_str!("demo-holidays-2026.csv"); + if !crate::holidays::compare(csv, &workspace.document.holidays)? + .additions + .is_empty() + { + workspace = + service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?; + } + let [alice, bob, carol, ..] = people; + let (variables, rules) = match index { + 1 => engineering_matrix(monday, people), + 2 => support_queues(monday, alice, carol), + 3 => regional_platform(monday, alice, bob), + _ => service_tiers(monday, alice, carol), + }; + let mut draft = Draft { + revision: workspace.revision, + variables, + rules, + route_position: 0, + review_id: None, + }; + let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?; + draft.review_id = report["review_id"].as_str().map(str::to_owned); + generation_service::apply(db, alice, workspace.id, draft).await?; + Ok(()) +} diff --git a/src/demo_runtime.rs b/src/demo_runtime.rs index 63ff5da..f1308c9 100644 --- a/src/demo_runtime.rs +++ b/src/demo_runtime.rs @@ -53,9 +53,18 @@ impl Retention { } } #[derive(Debug, Clone, Serialize)] +pub struct DemoAccount { + pub username: String, + pub role: String, +} +/// Everything is shown on the login screen: demonstration runs are loopback-only and +/// disposable, and every restart generates a new password. +#[derive(Debug, Clone, Serialize)] pub struct Notice { pub isolated: bool, pub retention: Retention, + pub password: String, + pub accounts: Vec, } static NOTICE: OnceLock = OnceLock::new(); pub fn notice() -> Option<&'static Notice> { @@ -191,7 +200,7 @@ pub async fn reset(db: &Database) -> Result<()> { LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE; IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF; - SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL; + SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins','meguri__instance_audit','meguri__instance_state','meguri__oidc_flows']) name WHERE to_regclass('public.'||name) IS NOT NULL; IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF; UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id; END $$;").await?; diff --git a/src/domain.rs b/src/domain.rs index 0d151bd..7e3e946 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -31,6 +31,14 @@ impl Permission { pub fn member() -> Vec { vec![Self::View, Self::RequestOverrides] } + /// Default employee role: calendar access, schedule edits, and approved replacements. + pub fn employee_default() -> Vec { + vec![Self::View, Self::EditSchedules, Self::RequestOverrides] + } + /// Permissions an administrator may grant the employee role. + pub fn grantable_to_employees(self) -> bool { + !matches!(self, Self::ManageMembers | Self::ManageIntegration) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -43,12 +51,19 @@ pub struct User { pub country: String, /// None follows the browser timezone; this never changes scheduling rules. pub timezone: Option, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub instance_admin: bool, + #[serde(default)] + pub has_password: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Member { pub user: User, pub permissions: Vec, + pub is_owner: bool, } /// Scheduling metadata, not an authentication principal. Registered users retain their ID. @@ -59,6 +74,9 @@ pub struct Person { pub email: Option, pub country: Option, pub user_id: Option, + /// Former contact IDs that were merged into this person when a login was linked by email. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -85,6 +103,9 @@ pub struct WorkspaceDocument { pub name: String, pub timezone: String, pub horizon_days: u16, + /// Grants held by the employee role; administrators always hold every permission. + #[serde(default = "Permission::employee_default")] + pub member_permissions: Vec, #[serde(default)] pub people: Vec, #[serde(default)] @@ -113,6 +134,7 @@ impl WorkspaceDocument { overwrite_drift: false, timezone: "Europe/Bucharest".into(), horizon_days: 90, + member_permissions: Permission::employee_default(), people: vec![], schedules: vec![], holidays: vec![], diff --git a/src/engine.rs b/src/engine.rs index 4957197..2dec5f0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -378,6 +378,7 @@ mod tests { display_name: format!("Person {i}"), email: None, country: Some(if i == 0 { "AM" } else { "CY" }.into()), + aliases: vec![], }) .collect(); let day = "2026-03-30".parse().unwrap(); diff --git a/src/generation.rs b/src/generation.rs index 30f6e0e..9737750 100644 --- a/src/generation.rs +++ b/src/generation.rs @@ -624,6 +624,7 @@ fn recipient( display_name: email.clone(), email: Some(email), country: Some("CY".into()), + aliases: vec![], }); Ok(id) } diff --git a/src/generation_service.rs b/src/generation_service.rs index 59768b7..67d622f 100644 --- a/src/generation_service.rs +++ b/src/generation_service.rs @@ -67,6 +67,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res display_name: user.display_name.clone(), email: None, country: Some(user.country.clone()), + aliases: vec![], }); } } diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..28a9ea3 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,975 @@ +//! Instance identity: configuration sync, group-derived access, SSO accounts, bootstrap +//! accounts and contact linking. Authorization stays on the server; the browser only +//! displays what these functions decide. +use crate::{ + access::{Access, Config, Role, WorkspaceConfig, bootstrap_password, resolve_access}, + domain::*, + error::{Error, Result, ensure}, + models::*, + service, + store::{self, Store, WorkspaceRecord}, +}; +use chrono::{DateTime, Utc}; +use cot::db::{DatabaseError, Model, query}; +use serde_json::json; +use std::collections::BTreeSet; +use uuid::Uuid; + +fn config_error(message: impl Into) -> Error { + Error::Validation(format!("Invalid configuration: {}", message.into())) +} + +pub fn normalize_email(value: &str) -> Option { + let email = value.trim().to_lowercase(); + (email.len() <= 254 && email_address::EmailAddress::is_valid(&email)).then_some(email) +} + +pub fn new_user_row(username: String, display_name: String, hash: Option) -> UserRow { + UserRow { + language: None, + demo_deleted_at: None, + id: Uuid::new_v4().to_string(), + username, + display_name, + country: "CY".into(), + timezone: None, + password_hash: hash, + created_at: Utc::now(), + email: None, + instance_admin: false, + bootstrap: false, + groups: "[]".into(), + } +} + +async fn insert_user(db: &Store, row: &UserRow) -> Result { + match row.clone().insert(&db.db).await { + Ok(_) => Ok(true), + Err(DatabaseError::UniqueViolation) => Ok(false), + Err(e) => Err(e.into()), + } +} + +// ---- workspaces from configuration --------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceChange { + Created, + Adopted, + Enabled, + Renamed, +} + +#[derive(Debug, Clone)] +pub struct EnsuredWorkspace { + pub id: Uuid, + pub changes: Vec, +} + +const CREATE_CONFIGURED_WORKSPACE: &str = r#" +WITH workspace AS ( + INSERT INTO meguri__workspaces(id,key,document,revision,created_at) VALUES($1,$2,$3,1,now()) RETURNING * +), history AS ( + INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),NULL FROM workspace +), jobs AS ( + INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace +) +INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.created',$4,now() FROM workspace +"#; + +const ENABLE_WORKSPACE: &str = r#" +WITH enabled AS ( + UPDATE meguri__workspaces SET disabled_at=NULL WHERE id=$1 AND disabled_at IS NOT NULL AND demo_deleted_at IS NULL RETURNING id,revision +), jobs AS ( + INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,now() FROM enabled + ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL +) +INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.enabled','{}',now() FROM enabled +"#; + +const DISABLE_WORKSPACE: &str = r#" +WITH disabled AS ( + UPDATE meguri__workspaces SET disabled_at=now() WHERE id=$1 AND disabled_at IS NULL AND demo_deleted_at IS NULL RETURNING id +), jobs AS ( + DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM disabled) +), members AS ( + DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM disabled) +) +INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.disabled','{}',now() FROM disabled +"#; + +/// Active (not demo-expired) workspace rows, including disabled ones. +async fn live_rows(db: &Store) -> Result> { + Ok(WorkspaceRow::objects() + .all(&db.db) + .await? + .into_iter() + .filter(|r| r.demo_deleted_at.is_none()) + .collect()) +} + +fn record_of(row: &WorkspaceRow) -> Result { + Ok(WorkspaceRecord { + id: store::uuid(&row.id)?, + revision: row.revision, + document: serde_json::from_str(&row.document)?, + }) +} + +fn ambiguous_adoption(config: &WorkspaceConfig, matches: usize) -> Error { + config_error(format!( + "workspace {:?} matches {matches} existing workspaces named {:?}; rename or delete the extras so the key can adopt exactly one", + config.key, config.name + )) +} + +/// Everything that can be refused from configuration alone is refused before the first write, +/// so a rejected configuration never leaves a half-applied instance behind. +async fn preflight(db: &Store, config: &Config, allow_disable_all: bool) -> Result<()> { + let rows = live_rows(db).await?; + if config.workspaces.is_empty() && !allow_disable_all { + let active = rows.iter().filter(|r| r.disabled_at.is_none()).count(); + ensure( + active == 0, + format!( + "Refusing to disable {active} existing workspaces because the configuration lists none. Fix the configuration or set MEGURI_ALLOW_DISABLE_ALL=true to disable them all." + ), + )?; + } + for workspace in &config.workspaces { + if rows + .iter() + .any(|r| r.key.as_deref() == Some(workspace.key.as_str())) + { + continue; + } + let mut matches = 0; + for row in rows.iter().filter(|r| r.key.is_none()) { + if record_of(row)?.document.name == workspace.name { + matches += 1; + } + } + if matches > 1 { + return Err(ambiguous_adoption(workspace, matches)); + } + } + for username in config.bootstrap_usernames() { + let name = username.clone(); + if let Some(row) = query!(UserRow,$username==name).get(&db.db).await? { + let id = row.id; + if query!(ExternalIdentityRow,$user_id==id) + .exists(&db.db) + .await? + { + return Err(config_error(format!( + "bootstrap username {username:?} belongs to an SSO account; choose another name" + ))); + } + } + } + Ok(()) +} + +/// Creates, adopts (by exact name), enables or renames the workspace declared by `config`. +/// Every step is idempotent, so replicas that run it concurrently converge. +pub async fn ensure_workspace(db: &Store, config: &WorkspaceConfig) -> Result { + for _ in 0..4 { + let rows = live_rows(db).await?; + if let Some(row) = rows + .iter() + .find(|r| r.key.as_deref() == Some(config.key.as_str())) + { + let id = store::uuid(&row.id)?; + let mut changes = vec![]; + if row.disabled_at.is_some() { + let result = db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?; + if result.rows_affected().0 > 0 { + changes.push(WorkspaceChange::Enabled); + } + } + let mut record = record_of(row)?; + if record.document.name != config.name { + let before = record.document.name.clone(); + record.document.name = config.name.clone(); + match db + .system_commit( + &mut record, + None, + "workspace.renamed", + json!({"from":before,"to":config.name}), + ) + .await + { + Ok(()) => changes.push(WorkspaceChange::Renamed), + Err(Error::Conflict(_)) => continue, + Err(e) => return Err(e), + } + } + return Ok(EnsuredWorkspace { id, changes }); + } + let mut candidates = vec![]; + for row in rows.iter().filter(|r| r.key.is_none()) { + if record_of(row)?.document.name == config.name { + candidates.push(row); + } + } + if candidates.len() > 1 { + return Err(ambiguous_adoption(config, candidates.len())); + } + if let Some(row) = candidates.first() { + let adopted = db + .db + .raw_with( + "UPDATE meguri__workspaces SET key=$2 WHERE id=$1 AND key IS NULL", + &[&row.id, &config.key], + ) + .await?; + if adopted.rows_affected().0 == 0 { + continue; + } + let mut changes = vec![WorkspaceChange::Adopted]; + if row.disabled_at.is_some() { + db.db.raw_with(ENABLE_WORKSPACE, &[&row.id]).await?; + changes.push(WorkspaceChange::Enabled); + } + return Ok(EnsuredWorkspace { + id: store::uuid(&row.id)?, + changes, + }); + } + let id = Uuid::new_v4(); + let document = serde_json::to_string(&WorkspaceDocument::new(config.name.clone()))?; + let details = json!({"key":config.key}).to_string(); + match db + .db + .raw_with( + CREATE_CONFIGURED_WORKSPACE, + &[&id.to_string(), &config.key, &document, &details], + ) + .await + { + Ok(_) => { + return Ok(EnsuredWorkspace { + id, + changes: vec![WorkspaceChange::Created], + }); + } + Err(DatabaseError::UniqueViolation) => continue, + Err(e) => return Err(e.into()), + } + } + Err(Error::Internal(format!( + "workspace {:?} kept changing while it was being created", + config.key + ))) +} + +/// The enabled workspace declared with `key`, if it exists. +pub async fn find_workspace(db: &Store, key: &str) -> Result> { + live_rows(db) + .await? + .iter() + .find(|r| r.key.as_deref() == Some(key) && r.disabled_at.is_none()) + .map(record_of) + .transpose() +} + +pub async fn disable_workspace(db: &Store, id: &str) -> Result { + Ok(db + .db + .raw_with(DISABLE_WORKSPACE, &[&id]) + .await? + .rows_affected() + .0 + > 0) +} + +// ---- group-derived access ------------------------------------------------------------- + +const APPLY_ACCESS: &str = r#" +WITH changed AS ( + UPDATE meguri__users SET groups=$2,instance_admin=$3 WHERE id=$1 RETURNING id +), desired AS ( + SELECT w.id AS workspace_id,d.is_owner FROM jsonb_to_recordset($4::jsonb) AS d(key text,is_owner boolean) + JOIN meguri__workspaces w ON w.key=d.key AND w.demo_deleted_at IS NULL AND w.disabled_at IS NULL +), removed AS ( + DELETE FROM meguri__workspace_members m WHERE m.user_id=$1 AND EXISTS(SELECT 1 FROM changed) + AND NOT EXISTS(SELECT 1 FROM desired d WHERE d.workspace_id=m.workspace_id) +) +INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) +SELECT d.workspace_id||':'||$1,d.workspace_id,$1, + CASE WHEN d.is_owner THEN $5 ELSE COALESCE((SELECT w.document::jsonb->'member_permissions' FROM meguri__workspaces w WHERE w.id=d.workspace_id)::text,$6) END, + d.is_owner +FROM desired d WHERE EXISTS(SELECT 1 FROM changed) +ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions,is_owner=EXCLUDED.is_owner +"#; + +pub fn stored_groups(row: &UserRow) -> Result> { + Ok(serde_json::from_str(&row.groups)?) +} + +/// The one place that turns groups into memberships and the instance-admin flag. SSO login, +/// the administrator's password-user editor and startup reconciliation all end here. +/// `groups` replaces the stored list when given. +pub async fn apply_access( + db: &Store, + config: &Config, + user: Uuid, + groups: Option>, +) -> Result { + let id = user.to_string(); + let row = query!(UserRow,$id==id.clone()) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + let mut groups = match groups { + Some(groups) => groups, + None => stored_groups(&row)?, + }; + groups.sort(); + groups.dedup(); + let access = resolve_access(config, &groups, row.bootstrap); + let desired: Vec<_> = access + .workspaces + .iter() + .map(|(key, role)| json!({"key":key,"is_owner":*role == Role::Admin})) + .collect(); + db.db + .raw_with( + APPLY_ACCESS, + &[ + &id, + &serde_json::to_string(&groups)?, + &access.instance_admin, + &serde_json::to_string(&desired)?, + &serde_json::to_string(&Permission::all())?, + &serde_json::to_string(&Permission::employee_default())?, + ], + ) + .await?; + Ok(access) +} + +pub async fn reconcile_all(db: &Store, config: &Config) -> Result { + let mut count = 0; + for row in UserRow::objects().all(&db.db).await? { + if row.demo_deleted_at.is_some() { + continue; + } + apply_access(db, config, store::uuid(&row.id)?, None).await?; + count += 1; + } + Ok(count) +} + +// ---- bootstrap accounts --------------------------------------------------------------- + +pub enum BootstrapPasswords { + /// HMAC of a database-held secret and the current UTC hour: identical across replicas. + Derived, + /// One generated password for a single-process demonstration run. + Fixed(String), +} + +async fn instance_secret(db: &Store) -> Result> { + let candidate = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + db.db + .raw_with( + "INSERT INTO meguri__instance_state(key,value) VALUES('bootstrap_secret',$1) ON CONFLICT(key) DO NOTHING", + &[&candidate], + ) + .await?; + let secret = query!(InstanceStateRow,$key=="bootstrap_secret".to_owned()) + .get(&db.db) + .await? + .ok_or_else(|| Error::Internal("bootstrap secret is missing".into()))?; + Ok(secret.value.into_bytes()) +} + +/// Creates or refreshes one bootstrap account. Returns whether the password changed. +async fn ensure_bootstrap_account(db: &Store, username: &str, password: &str) -> Result { + for _ in 0..3 { + let name = username.to_owned(); + match query!(UserRow,$username==name).get(&db.db).await? { + Some(row) => { + let id = row.id.clone(); + if query!(ExternalIdentityRow,$user_id==id) + .exists(&db.db) + .await? + { + return Err(config_error(format!( + "bootstrap username {username:?} belongs to an SSO account; choose another name" + ))); + } + if row.bootstrap + && let Some(hash) = row.password_hash.clone() + && service::password_matches(hash, password.to_owned()).await? + { + return Ok(false); + } + let hash = service::hash_password(password.to_owned()).await?; + db.db + .raw_with( + "UPDATE meguri__users SET password_hash=$2,bootstrap=true,demo_deleted_at=NULL WHERE id=$1", + &[&row.id, &hash], + ) + .await?; + db.db + .raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id]) + .await?; + return Ok(true); + } + None => { + let hash = service::hash_password(password.to_owned()).await?; + let mut row = new_user_row(username.to_owned(), username.to_owned(), Some(hash)); + row.bootstrap = true; + if insert_user(db, &row).await? { + return Ok(true); + } + } + } + } + Err(Error::Internal(format!( + "could not create bootstrap account {username:?}" + ))) +} + +/// Accounts that stopped being bootstrap accounts lose their password and their sessions. +async fn release_bootstrap_accounts(db: &Store, keep: &[String]) -> Result> { + let mut released = vec![]; + for row in query!(UserRow,$bootstrap==true).all(&db.db).await? { + if keep.contains(&row.username) { + continue; + } + db.db + .raw_with( + "UPDATE meguri__users SET bootstrap=false,password_hash=NULL WHERE id=$1", + &[&row.id], + ) + .await?; + db.db + .raw_with("DELETE FROM meguri__sessions WHERE user_id=$1", &[&row.id]) + .await?; + released.push(row.username); + } + Ok(released) +} + +// ---- synchronization ---------------------------------------------------------------- + +#[derive(Debug, Default, Clone)] +pub struct SyncReport { + pub created: Vec, + pub adopted: Vec, + pub enabled: Vec, + pub renamed: Vec, + pub disabled: Vec, + pub bootstrap_rotated: Vec, + pub bootstrap_released: Vec, + /// Current username/password pairs; the caller decides where to show them. + pub bootstrap_credentials: Vec<(String, String)>, +} + +pub struct SyncOptions { + pub passwords: BootstrapPasswords, + pub now: DateTime, + /// Allows a configuration without workspaces to disable every existing one. + pub allow_disable_all: bool, +} + +/// Makes the database match the declarative configuration: configured workspaces exist and are +/// enabled, other workspaces are disabled (never deleted), bootstrap accounts match the +/// configured list, and every user's memberships follow their stored groups. +pub async fn sync_config(db: &Store, config: &Config, options: SyncOptions) -> Result { + config.validate()?; + preflight(db, config, options.allow_disable_all).await?; + let mut report = SyncReport::default(); + let mut keys = BTreeSet::new(); + for workspace in &config.workspaces { + keys.insert(workspace.key.clone()); + let ensured = ensure_workspace(db, workspace).await?; + for change in &ensured.changes { + let (list, action) = match change { + WorkspaceChange::Created => (&mut report.created, "workspace.created"), + WorkspaceChange::Adopted => (&mut report.adopted, "workspace.adopted"), + WorkspaceChange::Enabled => (&mut report.enabled, "workspace.enabled"), + WorkspaceChange::Renamed => (&mut report.renamed, "workspace.renamed"), + }; + list.push(workspace.key.clone()); + db.instance_event( + None, + action, + json!({"key":workspace.key,"id":ensured.id,"name":workspace.name}), + ) + .await?; + } + } + for row in live_rows(db).await? { + let configured = row.key.as_ref().is_some_and(|k| keys.contains(k)); + if !configured && row.disabled_at.is_none() && disable_workspace(db, &row.id).await? { + let name = record_of(&row)?.document.name; + report + .disabled + .push(row.key.clone().unwrap_or_else(|| name.clone())); + db.instance_event( + None, + "workspace.disabled", + json!({"key":row.key,"id":row.id,"name":name}), + ) + .await?; + } + } + let usernames = config.bootstrap_usernames(); + report.bootstrap_released = release_bootstrap_accounts(db, usernames).await?; + if !usernames.is_empty() { + let secret = match options.passwords { + BootstrapPasswords::Derived => Some(instance_secret(db).await?), + BootstrapPasswords::Fixed(_) => None, + }; + for username in usernames { + let password = match (&options.passwords, &secret) { + (BootstrapPasswords::Fixed(password), _) => password.clone(), + (BootstrapPasswords::Derived, Some(secret)) => { + bootstrap_password(secret, username, options.now) + } + _ => unreachable!("derived passwords always load the instance secret"), + }; + if ensure_bootstrap_account(db, username, &password).await? { + report.bootstrap_rotated.push(username.clone()); + db.instance_event(None, "bootstrap.password_set", json!({"username":username})) + .await?; + } + report + .bootstrap_credentials + .push((username.clone(), password)); + } + } + reconcile_all(db, config).await?; + for (list, label) in [ + (&report.created, "created"), + (&report.adopted, "adopted"), + (&report.enabled, "enabled"), + (&report.renamed, "renamed"), + (&report.disabled, "disabled"), + ] { + if !list.is_empty() { + tracing::info!(workspaces = ?list, "workspaces {label} from configuration"); + } + } + Ok(report) +} + +// ---- SSO accounts ------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct SsoProfile { + pub issuer: String, + pub subject: String, + pub email: Option, + pub username_hint: Option, + pub display_name: Option, + pub groups: Vec, +} + +#[derive(Debug)] +pub enum SsoOutcome { + /// The provider's groups map to nothing; no account is created or kept enabled. + NoAccess, + SignedIn { + user: Box, + token: String, + csrf: String, + }, +} + +fn username_candidates(profile: &SsoProfile) -> Vec { + let clean = |value: &str| -> String { + value + .trim() + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c) { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .chars() + .take(56) + .collect() + }; + let mut bases: Vec = [ + profile.username_hint.as_deref(), + profile.email.as_deref().and_then(|e| e.split('@').next()), + ] + .into_iter() + .flatten() + .map(clean) + .filter(|b| b.len() >= 3) + .collect(); + if bases.is_empty() { + bases.push("user".into()); + } + let suffix = &service::token_hash(&format!("{}\n{}", profile.issuer, profile.subject))[..6]; + let mut candidates = vec![]; + for base in &bases { + candidates.push(base.clone()); + candidates.push(format!("{base}-{suffix}")); + } + candidates +} + +async fn find_identity(db: &Store, profile: &SsoProfile) -> Result> { + let (issuer, subject) = (profile.issuer.clone(), profile.subject.clone()); + Ok( + query!(ExternalIdentityRow,$issuer==issuer && $subject==subject) + .get(&db.db) + .await? + .map(|i| i.user_id), + ) +} + +async fn create_sso_user(db: &Store, profile: &SsoProfile) -> Result { + for username in username_candidates(profile) { + let display = profile + .display_name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty() && n.len() <= 120) + .unwrap_or(&username) + .to_owned(); + let mut row = new_user_row(username, display, None); + row.email = profile.email.clone(); + if !insert_user(db, &row).await? { + continue; + } + let mut identity = ExternalIdentityRow { + id: Uuid::new_v4().to_string(), + issuer: profile.issuer.clone(), + subject: profile.subject.clone(), + user_id: row.id.clone(), + }; + match identity.insert(&db.db).await { + Ok(_) => return Ok(row.id), + Err(DatabaseError::UniqueViolation) => { + db.db + .raw_with("DELETE FROM meguri__users WHERE id=$1", &[&row.id]) + .await?; + return find_identity(db, profile).await?.ok_or_else(|| { + Error::Internal("SSO identity disappeared during sign-in".into()) + }); + } + Err(e) => return Err(e.into()), + } + } + Err(Error::Internal( + "could not derive a unique username for the SSO account".into(), + )) +} + +/// Signs in a verified SSO identity. Groups are applied before anything else, so access that +/// was revoked in the provider disappears at this login and an unmapped user stays out. +pub async fn sso_sign_in(db: &Store, config: &Config, profile: SsoProfile) -> Result { + let existing = find_identity(db, &profile).await?; + let user_id = match existing { + Some(id) => id, + None => { + if resolve_access(config, &profile.groups, false).is_empty() { + return Ok(SsoOutcome::NoAccess); + } + create_sso_user(db, &profile).await? + } + }; + let id = store::uuid(&user_id)?; + let access = apply_access(db, config, id, Some(profile.groups.clone())).await?; + if access.is_empty() { + return Ok(SsoOutcome::NoAccess); + } + if let Some(email) = &profile.email { + db.db + .raw_with( + "UPDATE meguri__users SET email=$2 WHERE id=$1 AND email IS DISTINCT FROM $2", + &[&user_id, email], + ) + .await?; + } + if let Err(error) = link_contacts(db, id).await { + tracing::warn!(user = %id, %error, "could not link the sign-in email to workspace contacts"); + } + let row = query!(UserRow,$id==user_id) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + let user = store::user(row)?; + let (token, csrf) = service::create_session(db, user.id).await?; + Ok(SsoOutcome::SignedIn { + user: Box::new(user), + token, + csrf, + }) +} + +// ---- contact linking --------------------------------------------------------------- + +/// In every workspace the user belongs to, binds the user's email to the scheduling person +/// that carries it, so people planned by email are ready at first sign-in: +/// * a contact with the same email becomes the user (the contact's old ID is kept as an alias +/// and every reference is rewritten, preserving schedules, requests and variables); +/// * otherwise the user's own person gains the email, or is added with it. +/// +/// Ambiguous or conflicting data is skipped, never guessed. Access is never granted by +/// email: only group membership creates workspace access. +pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> { + let id = user.to_string(); + let row = query!(UserRow,$id==id.clone()) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + let Some(email) = row.email.as_deref().and_then(normalize_email) else { + return Ok(()); + }; + for membership in query!(WorkspaceMemberRow,$user_id==id).all(&db.db).await? { + let workspace = store::uuid(&membership.workspace_id)?; + for _ in 0..3 { + let Ok(mut record) = db.workspace(workspace).await else { + break; + }; + let members = db.member_ids(workspace).await?; + let Some((document, details)) = linked_document(&record.document, &row, user, &email) + else { + break; + }; + record.document = document; + if record.document.validate(&members).is_err() { + tracing::warn!(%workspace, %user, "contact linking skipped: the result would be invalid"); + break; + } + match db + .system_commit(&mut record, Some(user), "person.linked", details) + .await + { + Ok(()) => break, + Err(Error::Conflict(_)) => continue, + Err(e) => return Err(e), + } + } + } + Ok(()) +} + +/// Pure transformation for [`link_contacts`]. `None` means nothing to change. +pub fn linked_document( + document: &WorkspaceDocument, + user: &UserRow, + user_id: Uuid, + email: &str, +) -> Option<(WorkspaceDocument, serde_json::Value)> { + let contacts: Vec = document + .people + .iter() + .enumerate() + .filter(|(_, p)| p.email.as_deref() == Some(email) && p.id != user_id) + .map(|(i, _)| i) + .collect(); + if contacts.len() > 1 { + return None; + } + if let Some(&index) = contacts.first() { + let contact = &document.people[index]; + if contact.user_id.is_some() { + return None; + } + if document + .people + .iter() + .any(|p| p.id == user_id && p.email.as_deref().is_some_and(|e| e != email)) + { + return None; + } + let old = contact.id; + let mut working = document.clone(); + working.people.retain(|p| p.id != user_id); + let text = serde_json::to_string(&working) + .ok()? + .replace(&old.to_string(), &user_id.to_string()); + let mut linked: WorkspaceDocument = serde_json::from_str(&text).ok()?; + let person = linked.people.iter_mut().find(|p| p.id == user_id)?; + person.user_id = Some(user_id); + if !person.aliases.contains(&old) { + person.aliases.push(old); + } + return Some(( + linked, + json!({"user_id":user_id,"contact_id":old,"merged":true}), + )); + } + match document.people.iter().position(|p| p.id == user_id) { + Some(index) => { + let person = &document.people[index]; + if person.email.is_some() && person.user_id == Some(user_id) { + return None; + } + if person.email.as_deref().is_some_and(|e| e != email) { + return None; + } + let mut linked = document.clone(); + linked.people[index].email = Some(email.to_owned()); + linked.people[index].user_id = Some(user_id); + Some((linked, json!({"user_id":user_id,"merged":false}))) + } + None => { + let mut linked = document.clone(); + linked.people.push(Person { + id: user_id, + user_id: Some(user_id), + display_name: user.display_name.clone(), + email: Some(email.to_owned()), + country: Some(user.country.clone()), + aliases: vec![], + }); + Some((linked, json!({"user_id":user_id,"merged":false}))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row() -> UserRow { + new_user_row("dana".into(), "Dana".into(), None) + } + fn contact(email: &str) -> Person { + Person { + id: Uuid::new_v4(), + user_id: None, + display_name: "Contact".into(), + email: Some(email.into()), + country: Some("AM".into()), + aliases: vec![], + } + } + + #[test] + fn a_matching_contact_becomes_the_user_everywhere_without_duplicates() { + let user = Uuid::new_v4(); + let existing_self = Person { + id: user, + user_id: Some(user), + display_name: "Dana".into(), + email: None, + country: Some("CY".into()), + aliases: vec![], + }; + let mut doc = WorkspaceDocument::new("Team".into()); + let c = contact("dana@corp.test"); + let old = c.id; + doc.people = vec![existing_self, c]; + doc.generation.variables = vec![]; + doc.absences.push(Absence { + id: Uuid::new_v4(), + user_id: old, + start: "2026-11-01".parse().unwrap(), + end: "2026-11-02".parse().unwrap(), + }); + let (linked, details) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap(); + assert_eq!(linked.people.len(), 1, "no second entry for the same user"); + let person = &linked.people[0]; + assert_eq!((person.id, person.user_id), (user, Some(user))); + assert_eq!(person.aliases, vec![old]); + assert_eq!(person.display_name, "Contact"); + assert_eq!(linked.absences[0].user_id, user); + assert_eq!(details["contact_id"], json!(old)); + assert_eq!( + serde_json::to_string(&linked) + .unwrap() + .matches(&old.to_string()) + .count(), + 1, + "the old id survives only as the alias" + ); + // Applying the result again is a no-op. + assert!(linked_document(&linked, &row(), user, "dana@corp.test").is_none()); + } + + #[test] + fn ambiguous_or_conflicting_data_is_never_guessed() { + let user = Uuid::new_v4(); + let mut doc = WorkspaceDocument::new("Team".into()); + // Two contacts with one email cannot validate, and must not be merged either. + doc.people = vec![contact("dana@corp.test"), contact("dana@corp.test")]; + assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); + // A contact that already belongs to another login is not stolen. + let mut taken = contact("dana@corp.test"); + taken.user_id = Some(Uuid::new_v4()); + doc.people = vec![taken]; + assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); + // The user's own person carries a different email: leave both alone. + doc.people = vec![ + Person { + id: user, + user_id: Some(user), + display_name: "Dana".into(), + email: Some("other@corp.test".into()), + country: None, + aliases: vec![], + }, + contact("dana@corp.test"), + ]; + assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); + } + + #[test] + fn users_without_a_contact_gain_a_person_with_their_email() { + let user = Uuid::new_v4(); + let doc = WorkspaceDocument::new("Team".into()); + let (linked, _) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap(); + assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test")); + assert_eq!(linked.people[0].user_id, Some(user)); + let mut with_person = doc.clone(); + with_person.people.push(Person { + id: user, + user_id: Some(user), + display_name: "Dana".into(), + email: None, + country: None, + aliases: vec![], + }); + let (linked, _) = linked_document(&with_person, &row(), user, "dana@corp.test").unwrap(); + assert_eq!(linked.people.len(), 1); + assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test")); + } + + #[test] + fn usernames_are_clean_unique_candidates() { + let profile = |hint: Option<&str>, email: Option<&str>| SsoProfile { + issuer: "https://idp.test".into(), + subject: "sub-1".into(), + email: email.map(str::to_owned), + username_hint: hint.map(str::to_owned), + display_name: None, + groups: vec![], + }; + let candidates = username_candidates(&profile(Some("Jane Doe!"), Some("jd@corp.test"))); + assert_eq!(candidates[0], "jane-doe"); + assert!( + candidates[1].starts_with("jane-doe-") && candidates[1].len() == "jane-doe-".len() + 6 + ); + // Too-short hints fall back to the email name, and nothing at all to a neutral base. + assert_eq!( + username_candidates(&profile(Some("ab"), Some("Lena.Ivanova@corp.test")))[0], + "lena.ivanova" + ); + assert_eq!(username_candidates(&profile(None, None))[0], "user"); + assert!(candidates.iter().all(|c| crate::access::valid_username(c))); + let long = "x".repeat(200); + assert!( + username_candidates(&profile(Some(&long), None)) + .iter() + .all(|c| crate::access::valid_username(c)) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9fe410a..ce0a8da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod absence; +pub mod access; +pub mod admin; pub mod audit; pub mod configuration; pub mod demo; @@ -10,8 +12,10 @@ pub mod generation; pub mod generation_service; pub mod holidays; pub mod i18n; +pub mod identity; pub mod migrations; pub mod models; +pub mod oidc; pub mod providers; pub mod service; pub mod store; diff --git a/src/main.rs b/src/main.rs index 730886b..10e9227 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,30 @@ use cot::{ config::{DatabaseConfig, ProjectConfig}, }; use meguri::{ - demo_runtime, service, + access::{self, Config}, + demo, demo_runtime, identity, oidc, service, store::Store, web::{MeguriProject, enabled}, worker, }; +fn load_config(demo: bool) -> Result> { + if demo { + if std::env::var_os("MEGURI_CONFIG_FILE").is_some() { + tracing::warn!( + "Demo mode uses its built-in configuration; MEGURI_CONFIG_FILE is ignored" + ); + } + return Ok(demo::config()?); + } + let path = std::env::var("MEGURI_CONFIG_FILE").map_err(|_| { + "Set MEGURI_CONFIG_FILE to the instance configuration file (workspaces, group mappings and sign-in); see docs/deployment.md" + })?; + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("Cannot read MEGURI_CONFIG_FILE {path:?}: {e}"))?; + Ok(Config::parse(&text)?) +} + #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() @@ -22,6 +40,8 @@ async fn main() -> Result<(), Box> { if !database_url.starts_with("postgresql://") { return Err("DATABASE_URL must use the postgresql:// scheme".into()); } + let demo_mode = enabled("MEGURI_SEED_DEMO"); + let config = load_config(demo_mode)?; let role = std::env::var("MEGURI_ROLE").unwrap_or_else(|_| "all".into()); let bind = std::env::var("MEGURI_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into()); let public_url = @@ -31,6 +51,18 @@ async fn main() -> Result<(), Box> { if public.scheme() != "https" && !(public.scheme() == "http" && loopback) { return Err("MEGURI_PUBLIC_URL must use HTTPS outside localhost".into()); } + if let Some(settings) = config.oidc.clone() { + let client_secret = std::env::var("MEGURI_OIDC_CLIENT_SECRET") + .ok() + .filter(|s| !s.is_empty()) + .ok_or("Set MEGURI_OIDC_CLIENT_SECRET (a deployment secret) when oidc is configured")?; + oidc::install(oidc::Settings { + config: settings, + client_secret, + redirect_uri: format!("{}/api/oidc/callback", public_url.trim_end_matches('/')), + }); + } + access::install(config.clone()); if enabled("MEGURI_SEED_DEMO") && (!loopback || !bind.starts_with("127.0.0.1:")) { return Err("Demo accounts require a loopback bind and URL".into()); } @@ -45,9 +77,20 @@ async fn main() -> Result<(), Box> { database_url = selected; let connection = cot::db::Database::new(&database_url).await?; demo_runtime::reset(&connection).await?; + let password = access::random_password(); + tracing::warn!( + "Demo accounts ({}) sign in with the password {password}; it changes on every start and is also shown on the login screen", + demo::accounts() + .iter() + .map(|a| a.username.clone()) + .collect::>() + .join(", ") + ); demo_runtime::install(demo_runtime::Notice { isolated, retention, + password, + accounts: demo::accounts(), }); if isolated { tracing::warn!( @@ -65,8 +108,27 @@ async fn main() -> Result<(), Box> { } else { Store::connect(&database_url).await? }; + // Whoever owns the migrations also applies the declarative configuration. Demo mode + // synchronizes while seeding. Replicas started with MEGURI_SKIP_MIGRATIONS only read. + if !demo_mode && role != "worker" && !enabled("MEGURI_SKIP_MIGRATIONS") { + let report = identity::sync_config( + &db, + &config, + identity::SyncOptions { + passwords: identity::BootstrapPasswords::Derived, + now: chrono::Utc::now(), + allow_disable_all: enabled("MEGURI_ALLOW_DISABLE_ALL"), + }, + ) + .await?; + for (username, password) in &report.bootstrap_credentials { + tracing::warn!( + "Bootstrap administrator enabled: sign in as {username} with password {password} (it stays valid until a later start in another UTC hour replaces it). Remove auth.bootstrap_admin from the configuration to disable it." + ); + } + } if role == "migrate" { - tracing::info!("Cot migrations completed"); + tracing::info!("Cot migrations and configuration sync completed"); return Ok(()); } if enabled("MEGURI_SEED_DEMO") { diff --git a/src/migrations.rs b/src/migrations.rs index a2886e9..5affae6 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -6,6 +6,7 @@ pub mod m_0005_variables; pub mod m_0006_demo_lifecycle; pub mod m_0007_language; pub mod m_0008_workspaces; +pub mod m_0009_access; pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[ &m_0001_initial::Migration, &m_0002_constraints::Migration, @@ -15,4 +16,5 @@ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[ &m_0006_demo_lifecycle::Migration, &m_0007_language::Migration, &m_0008_workspaces::Migration, + &m_0009_access::Migration, ]; diff --git a/src/migrations/m_0009_access.rs b/src/migrations/m_0009_access.rs new file mode 100644 index 0000000..011abcd --- /dev/null +++ b/src/migrations/m_0009_access.rs @@ -0,0 +1,27 @@ +use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op}; +#[derive(Debug, Copy, Clone)] +pub struct Migration; +impl cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = "meguri"; + const MIGRATION_NAME: &'static str = "m_0009_access"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "meguri", + "m_0008_workspaces", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()]; +} +#[migration_op] +async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> { + context.db.raw(r#"DO $$ BEGIN + ALTER TABLE meguri__users ALTER COLUMN password_hash DROP NOT NULL; + ALTER TABLE meguri__users ADD COLUMN email text, ADD COLUMN instance_admin boolean NOT NULL DEFAULT false, ADD COLUMN bootstrap boolean NOT NULL DEFAULT false, ADD COLUMN groups text NOT NULL DEFAULT '[]' CHECK(jsonb_typeof(groups::jsonb)='array'); + ALTER TABLE meguri__workspaces ADD COLUMN key text, ADD COLUMN disabled_at timestamptz; + CREATE UNIQUE INDEX workspace_key_unique ON meguri__workspaces(key) WHERE key IS NOT NULL AND demo_deleted_at IS NULL; + CREATE UNIQUE INDEX external_identity_subject ON meguri__external_identities(issuer,subject); + CREATE INDEX external_identity_user ON meguri__external_identities(user_id); + CREATE TABLE meguri__instance_state(key text PRIMARY KEY,value text NOT NULL); + CREATE TABLE meguri__instance_audit(id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,actor_id text,action text NOT NULL,details text NOT NULL,created_at timestamptz NOT NULL); + CREATE TABLE meguri__oidc_flows(state_hash text PRIMARY KEY,nonce text NOT NULL,verifier text NOT NULL,expires_at timestamptz NOT NULL); + END $$;"#).await?; + Ok(()) +} diff --git a/src/models.rs b/src/models.rs index 7a70622..f90622e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -14,8 +14,14 @@ pub struct UserRow { pub display_name: String, pub country: String, pub timezone: Option, - pub password_hash: String, + /// None for accounts that sign in only through SSO or whose bootstrap access was removed. + pub password_hash: Option, pub created_at: DateTime, + pub email: Option, + pub instance_admin: bool, + pub bootstrap: bool, + /// JSON array of group names that map to workspace roles through the instance configuration. + pub groups: String, } #[derive(Debug, Clone)] @@ -47,6 +53,9 @@ pub struct WorkspaceRow { pub document: String, pub revision: i64, pub created_at: DateTime, + /// Stable identifier from the instance configuration; None for legacy workspaces. + pub key: Option, + pub disabled_at: Option>, } #[derive(Debug, Clone)] @@ -112,3 +121,32 @@ pub struct ProviderObjectRow { pub content: String, pub updated_at: DateTime, } + +#[derive(Debug, Clone)] +#[model(table_name = "oidc_flows")] +pub struct OidcFlowRow { + #[model(primary_key)] + pub state_hash: String, + pub nonce: String, + pub verifier: String, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +#[model(table_name = "instance_state")] +pub struct InstanceStateRow { + #[model(primary_key)] + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone)] +#[model(table_name = "instance_audit")] +pub struct InstanceAuditRow { + #[model(primary_key)] + pub id: Auto, + pub actor_id: Option, + pub action: String, + pub details: String, + pub created_at: DateTime, +} diff --git a/src/oidc.rs b/src/oidc.rs new file mode 100644 index 0000000..92d42fc --- /dev/null +++ b/src/oidc.rs @@ -0,0 +1,348 @@ +//! OpenID Connect authorization-code flow with PKCE for a confidential client. Tokens are +//! verified here (signature, issuer, audience, expiry, nonce); the rest of the application only +//! sees an already trusted profile. +use crate::{ + access::{GroupSource, OidcConfig, claim_groups}, + error::{Error, Result}, + identity::{SsoProfile, normalize_email}, + models::OidcFlowRow, + service, + store::Store, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{Duration, Utc}; +use cot::db::{Model, query}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, jwk::JwkSet}; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + sync::OnceLock, + time::{Duration as StdDuration, Instant}, +}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct Settings { + pub config: OidcConfig, + pub client_secret: String, + pub redirect_uri: String, +} + +static SETTINGS: OnceLock = OnceLock::new(); +pub fn install(settings: Settings) { + let _ = SETTINGS.set(settings); +} +pub fn settings() -> Option<&'static Settings> { + SETTINGS.get() +} + +/// How a sign-in attempt failed, in terms the browser can show. +#[derive(Debug)] +pub enum Failure { + /// The identity provider could not be reached or answered unusably. + Unavailable(String), + /// The callback or a token did not verify. + Invalid(String), +} +impl From for Error { + fn from(failure: Failure) -> Self { + match failure { + Failure::Unavailable(m) | Failure::Invalid(m) => Error::Provider(m), + } + } +} +type Outcome = std::result::Result; + +#[derive(Debug, Clone, Deserialize)] +struct Discovery { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + jwks_uri: String, + #[serde(default)] + userinfo_endpoint: Option, +} + +#[derive(Default)] +struct Cache { + discovery: Option<(Instant, Discovery)>, + jwks: Option<(Instant, JwkSet)>, +} +static CACHE: tokio::sync::Mutex = tokio::sync::Mutex::const_new(Cache { + discovery: None, + jwks: None, +}); +const DISCOVERY_TTL: StdDuration = StdDuration::from_secs(600); +const JWKS_MIN_REFRESH: StdDuration = StdDuration::from_secs(15); + +fn client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(StdDuration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("HTTP client") + }) +} + +fn unavailable(what: &str, error: impl std::fmt::Display) -> Failure { + Failure::Unavailable(format!("{what}: {error}")) +} + +async fn get_json( + what: &str, + request: reqwest::RequestBuilder, +) -> Outcome { + let response = request.send().await.map_err(|e| unavailable(what, e))?; + if !response.status().is_success() { + return Err(Failure::Unavailable(format!( + "{what}: HTTP {}", + response.status() + ))); + } + response.json().await.map_err(|e| unavailable(what, e)) +} + +async fn discovery(settings: &Settings) -> Outcome { + let mut cache = CACHE.lock().await; + if let Some((at, found)) = &cache.discovery + && at.elapsed() < DISCOVERY_TTL + { + return Ok(found.clone()); + } + let issuer = settings.config.issuer.trim_end_matches('/'); + let found: Discovery = get_json( + "OpenID discovery", + client().get(format!("{issuer}/.well-known/openid-configuration")), + ) + .await?; + if found.issuer.trim_end_matches('/') != issuer { + return Err(Failure::Invalid( + "discovery document issuer differs from oidc.issuer".into(), + )); + } + cache.discovery = Some((Instant::now(), found.clone())); + Ok(found) +} + +async fn key_for(settings: &Settings, kid: Option<&str>) -> Outcome { + let found = discovery(settings).await?; + let mut cache = CACHE.lock().await; + for attempt in 0..2 { + let fresh = cache + .jwks + .as_ref() + .is_some_and(|(at, _)| at.elapsed() < JWKS_MIN_REFRESH); + let hit = cache.jwks.as_ref().and_then(|(_, set)| { + set.keys.iter().find(|k| match kid { + Some(kid) => k.common.key_id.as_deref() == Some(kid), + None => set.keys.len() == 1, + }) + }); + if let Some(jwk) = hit { + return DecodingKey::from_jwk(jwk).map_err(|e| Failure::Invalid(e.to_string())); + } + if attempt == 1 || fresh { + break; + } + let set: JwkSet = get_json("JWKS", client().get(&found.jwks_uri)).await?; + cache.jwks = Some((Instant::now(), set)); + } + Err(Failure::Invalid("no signing key matches the token".into())) +} + +fn random_token() -> String { + format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +} + +/// Stores the one-time flow state and returns the provider URL plus the state that the +/// browser must present back through a cookie. +pub async fn begin(db: &Store) -> Result<(String, String)> { + let settings = settings().ok_or(Error::NotFound)?; + let found = discovery(settings).await?; + let (state, nonce, verifier) = (random_token(), random_token(), random_token()); + db.db + .raw("DELETE FROM meguri__oidc_flows WHERE expires_at, +} + +/// Verifies the callback and returns the profile of the authenticated person. The browser's +/// state cookie must equal the returned state, and the flow row can be used exactly once. +pub async fn complete( + db: &Store, + cookie_state: Option<&str>, + state: &str, + code: &str, +) -> std::result::Result { + let settings = settings().ok_or_else(|| Failure::Invalid("OIDC is not configured".into()))?; + if cookie_state != Some(state) || state.len() != 64 { + return Err(Failure::Invalid( + "state cookie is missing or differs from the callback state".into(), + )); + } + let hash = service::token_hash(state); + let flow = query!(OidcFlowRow,$state_hash==hash.clone()) + .get(&db.db) + .await + .map_err(|e| Failure::Unavailable(e.to_string()))? + .filter(|f| f.expires_at > Utc::now()) + .ok_or_else(|| Failure::Invalid("unknown or expired login attempt".into()))?; + let used = db + .db + .raw_with( + "DELETE FROM meguri__oidc_flows WHERE state_hash=$1", + &[&hash], + ) + .await + .map_err(|e| Failure::Unavailable(e.to_string()))?; + if used.rows_affected().0 != 1 { + return Err(Failure::Invalid("login attempt was already used".into())); + } + let found = discovery(settings).await?; + let response = client() + .post(&found.token_endpoint) + .basic_auth(&settings.config.client_id, Some(&settings.client_secret)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", settings.redirect_uri.as_str()), + ("code_verifier", flow.verifier.as_str()), + ]) + .send() + .await + .map_err(|e| unavailable("token exchange", e))?; + if !response.status().is_success() { + return Err(Failure::Invalid(format!( + "token exchange: HTTP {}", + response.status() + ))); + } + let tokens: Tokens = response + .json() + .await + .map_err(|e| unavailable("token response", e))?; + let claims = verify_id_token(settings, &tokens.id_token, &flow.nonce).await?; + profile(settings, &found, &claims, tokens.access_token.as_deref()).await +} + +/// Signature (RS256 only), issuer, audience, expiry and nonce checks. +pub async fn verify_id_token(settings: &Settings, token: &str, nonce: &str) -> Outcome { + let header = jsonwebtoken::decode_header(token).map_err(|e| Failure::Invalid(e.to_string()))?; + let key = key_for(settings, header.kid.as_deref()).await?; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&settings.config.client_id]); + validation.set_issuer(&[settings.config.issuer.trim_end_matches('/')]); + validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]); + let claims = match jsonwebtoken::decode::(token, &key, &validation) { + Ok(data) => data.claims, + Err(first) => { + // Providers differ on a trailing slash in `iss`; accept only that single variation. + let mut alternate = validation.clone(); + let issuer = settings.config.issuer.trim_end_matches('/'); + alternate.set_issuer(&[format!("{issuer}/")]); + jsonwebtoken::decode::(token, &key, &alternate) + .map_err(|_| Failure::Invalid(first.to_string()))? + .claims + } + }; + if claims.get("nonce").and_then(Value::as_str) != Some(nonce) { + return Err(Failure::Invalid("nonce mismatch".into())); + } + if let Some(audiences) = claims.get("aud").and_then(Value::as_array) + && audiences.len() > 1 + && claims.get("azp").and_then(Value::as_str) != Some(settings.config.client_id.as_str()) + { + return Err(Failure::Invalid("authorized party mismatch".into())); + } + Ok(claims) +} + +async fn userinfo(found: &Discovery, access_token: Option<&str>) -> Option { + let (endpoint, token) = (found.userinfo_endpoint.as_ref()?, access_token?); + get_json::("userinfo", client().get(endpoint).bearer_auth(token)) + .await + .map_err(|failure| tracing::warn!(?failure, "userinfo request failed")) + .ok() +} + +async fn profile( + settings: &Settings, + found: &Discovery, + claims: &Value, + access_token: Option<&str>, +) -> Outcome { + let path = &settings.config.groups_claim; + let mut info: Option = None; + let mut groups = None; + for source in &settings.config.groups_sources { + match source { + GroupSource::IdToken => groups = claim_groups(claims, path), + GroupSource::Userinfo => { + if info.is_none() { + info = Some(userinfo(found, access_token).await.unwrap_or(Value::Null)); + } + groups = info.as_ref().and_then(|i| claim_groups(i, path)); + } + } + if groups.is_some() { + break; + } + } + if groups.is_none() { + tracing::warn!( + claim = %path, + "the identity provider returned no groups claim; check the group membership mapper of the client" + ); + } + let text = |name: &str| -> Option { + claims + .get(name) + .or_else(|| info.as_ref().and_then(|i| i.get(name))) + .and_then(Value::as_str) + .map(str::to_owned) + }; + let subject = claims + .get("sub") + .and_then(Value::as_str) + .ok_or_else(|| Failure::Invalid("token has no subject".into()))? + .to_owned(); + Ok(SsoProfile { + issuer: settings.config.issuer.trim_end_matches('/').to_owned(), + subject, + email: text("email").and_then(|e| normalize_email(&e)), + username_hint: text("preferred_username"), + display_name: text("name"), + groups: groups.unwrap_or_default(), + }) +} diff --git a/src/service.rs b/src/service.rs index 080feaf..6941f3c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -38,57 +38,13 @@ pub async fn hash_password(password: String) -> Result { .map_err(|e| Error::Internal(e.to_string()))? } -pub async fn register( +/// Checks a password against the stored hash. Unknown users and accounts without a +/// password still pay for one key derivation so response time does not reveal either. +pub async fn verify_password( db: &Store, - username: String, - display_name: String, + username: &str, password: String, -) -> Result { - let username = username.trim().to_ascii_lowercase(); - ensure( - (3..=64).contains(&username.len()) - && username - .chars() - .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)), - crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"), - )?; - validate_name(&display_name)?; - let hash = hash_password(password).await?; - let user = User { - language: None, - id: Uuid::new_v4(), - username, - display_name, - country: "CY".into(), - timezone: None, - }; - let result = UserRow { - language: None, - demo_deleted_at: None, - id: user.id.to_string(), - username: user.username.clone(), - display_name: user.display_name.clone(), - country: user.country.clone(), - timezone: None, - password_hash: hash, - created_at: Utc::now(), - } - .insert(&db.db) - .await; - if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) { - return Err(Error::Conflict( - crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(), - )); - } - result?; - Ok(user) -} - -pub async fn login( - db: &Store, - username: String, - password: String, -) -> Result<(User, String, String)> { +) -> Result> { ensure( password.len() <= 256 && username.len() <= 64, crate::i18n::text("err_invalid_credentials_e41784"), @@ -96,13 +52,17 @@ pub async fn login( let username = username.trim().to_ascii_lowercase(); let row = query!(UserRow,$username==username).get(&db.db).await?; let row = row.filter(|r| r.demo_deleted_at.is_none()); - // Perform the expensive KDF for an unknown username too. let fallback = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$DKjSa6guFpKhutOgiqYmtDXrWFFJEWEbCJ3JAlCSF3c"; let hash: String = row .as_ref() - .map(|r| r.password_hash.clone()) + .and_then(|r| r.password_hash.clone()) .unwrap_or(fallback.into()); - let valid = tokio::task::spawn_blocking(move || { + let valid = password_matches(hash, password).await?; + Ok(row.filter(|r| valid && r.password_hash.is_some())) +} + +pub async fn password_matches(hash: String, password: String) -> Result { + tokio::task::spawn_blocking(move || { PasswordHash::new(&hash).is_ok_and(|h| { Argon2::default() .verify_password(password.as_bytes(), &h) @@ -110,41 +70,38 @@ pub async fn login( }) }) .await - .map_err(|e| Error::Internal(e.to_string()))?; - if !valid || row.is_none() { - return Err(Error::Unauthorized); - } - let row = row.unwrap(); - let user = store::user(row)?; + .map_err(|e| Error::Internal(e.to_string())) +} + +pub async fn create_session(db: &Store, user: Uuid) -> Result<(String, String)> { let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let csrf = Uuid::new_v4().to_string(); SessionRow { token_hash: token_hash(&token), - user_id: user.id.to_string(), + user_id: user.to_string(), csrf: csrf.clone(), expires_at: Utc::now() + Duration::hours(12), } .insert(&db.db) .await?; - Ok((user, token, csrf)) + Ok((token, csrf)) } -pub async fn create_workspace(db: &Store, actor: Uuid, name: String) -> Result { - validate_name(&name)?; - let id = Uuid::new_v4(); - let doc = WorkspaceDocument::new(name); - db.db - .raw_with( - CREATE_WORKSPACE, - &[ - &id.to_string(), - &serde_json::to_string(&doc)?, - &actor.to_string(), - &serde_json::to_string(&Permission::all())?, - ], - ) - .await?; - db.workspace(id).await +/// Password sign-in. Ordinary accounts need `password_login`; bootstrap accounts always may. +/// Every refusal is the same error as a wrong password. +pub async fn login( + db: &Store, + password_login: bool, + username: String, + password: String, +) -> Result<(User, String, String)> { + let row = verify_password(db, &username, password) + .await? + .filter(|r| password_login || r.bootstrap) + .ok_or(Error::Unauthorized)?; + let user = store::user(row)?; + let (token, csrf) = create_session(db, user.id).await?; + Ok((user, token, csrf)) } pub async fn update_document( @@ -375,36 +332,36 @@ pub async fn update_integration( Ok(record) } -pub async fn set_member( +/// Replaces the permission set of the employee role. Administrators always hold everything; +/// changing the role takes effect for every current and future employee in this workspace. +pub async fn set_member_permissions( db: &Store, actor: Uuid, workspace: Uuid, revision: i64, - username: String, - permissions: Vec, + mut permissions: Vec, ) -> Result { + permissions.sort(); + permissions.dedup(); ensure( permissions.contains(&Permission::View), crate::i18n::text("err_every_member_must_have_calendar_access_ca104e"), )?; + ensure( + permissions.iter().all(|p| p.grantable_to_employees()), + crate::i18n::text("err_employees_cannot_manage_members_or_integrations_3c21aa"), + )?; let mut record = db .prepare(workspace, revision, actor, Permission::ManageMembers) .await?; - let username = username.trim().to_ascii_lowercase(); - let user = query!(UserRow,$username==username) - .get(&db.db) - .await? - .ok_or(Error::Validation( - crate::i18n::text("err_ask_this_person_to_create_an_account_first_897eb2").into(), - ))?; - let user = store::uuid(&user.id)?; + record.document.member_permissions = permissions.clone(); db.commit( &mut record, actor, Permission::ManageMembers, - "member.permissions_changed", - json!({"user_id":user,"permissions":permissions}), - Some((user, permissions)), + "member.role_permissions_changed", + json!({"permissions":permissions}), + Some(permissions), ) .await?; Ok(record) @@ -921,6 +878,21 @@ pub async fn seed_demo(db: &Store) -> Result<()> { pub async fn list_workspaces(db: &Store, user: Uuid) -> Result { let actor = user.to_string(); let mut workspaces = vec![]; + if db.is_instance_admin(user).await? { + // Instance administrators see every enabled workspace without belonging to it. + for row in WorkspaceRow::objects().all(&db.db).await? { + let key = row.key.clone(); + match store::record(row) { + Ok(record) => workspaces.push( + json!({"id":record.id,"key":key,"name":record.document.name,"is_owner":true}), + ), + Err(Error::NotFound) => {} + Err(e) => return Err(e), + } + } + workspaces.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + return Ok(json!({"workspaces":workspaces})); + } for membership in query!(WorkspaceMemberRow,$user_id==actor) .all(&db.db) .await? @@ -930,31 +902,19 @@ pub async fn list_workspaces(db: &Store, user: Uuid) -> Result { .get(&db.db) .await? .ok_or(Error::NotFound)?; - if row.demo_deleted_at.is_some() { + let key = row.key.clone(); + let Ok(record) = store::record(row) else { continue; - } - let record = store::record(row)?; + }; if db.require(record.id, user, Permission::View).await.is_ok() { workspaces.push( - json!({"id":record.id,"name":record.document.name,"is_owner":membership.is_owner}), + json!({"id":record.id,"key":key,"name":record.document.name,"is_owner":membership.is_owner}), ); } } + workspaces.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); Ok(json!({"workspaces":workspaces})) } -const CREATE_WORKSPACE: &str = r#" -WITH workspace AS ( - INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now()) RETURNING * -), member AS ( - INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT id||':'||$3,id,$3,$4,true FROM workspace -), history AS ( - INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),$3 FROM workspace -), jobs AS ( - INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace -) -INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$3,'workspace.created','{}',now() FROM workspace -"#; - const SAVE_PROFILE: &str = r#" WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KEY UPDATE), changed AS ( UPDATE meguri__users SET display_name=$2,country=$3,timezone=$4,password_hash=$5,language=$6 WHERE id IN(SELECT id FROM previous) RETURNING * diff --git a/src/store.rs b/src/store.rs index e353af5..955f24d 100644 --- a/src/store.rs +++ b/src/store.rs @@ -35,10 +35,13 @@ pub fn user(row: UserRow) -> Result { display_name: row.display_name, country: row.country, timezone: row.timezone, + email: row.email, + instance_admin: row.instance_admin, + has_password: row.password_hash.is_some(), }) } pub fn record(row: WorkspaceRow) -> Result { - if row.demo_deleted_at.is_some() { + if row.demo_deleted_at.is_some() || row.disabled_at.is_some() { return Err(Error::NotFound); } Ok(WorkspaceRecord { @@ -66,8 +69,24 @@ impl Store { .ok_or(Error::NotFound)?, ) } + pub async fn is_instance_admin(&self, user: Uuid) -> Result { + let id = user.to_string(); + Ok(query!(UserRow,$id==id && $instance_admin==true) + .exists(&self.db) + .await?) + } + pub async fn workspace_key(&self, workspace: Uuid) -> Result> { + let id = workspace.to_string(); + Ok(query!(WorkspaceRow,$id==id) + .get(&self.db) + .await? + .and_then(|row| row.key)) + } pub async fn permissions(&self, workspace: Uuid, actor: Uuid) -> Result> { self.workspace(workspace).await?; + if self.is_instance_admin(actor).await? { + return Ok(Permission::all()); + } let workspace = workspace.to_string(); let actor = actor.to_string(); let row = query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor) @@ -95,6 +114,9 @@ impl Store { } pub async fn owner(&self, workspace: Uuid, actor: Uuid) -> Result<()> { self.workspace(workspace).await?; + if self.is_instance_admin(actor).await? { + return Ok(()); + } let workspace = workspace.to_string(); let actor = actor.to_string(); if query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor && $is_owner==true) @@ -125,6 +147,7 @@ impl Store { } else { serde_json::from_str(&m.permissions)? }, + is_owner: m.is_owner, }); } members.sort_by(|a, b| a.user.display_name.cmp(&b.user.display_name)); @@ -166,8 +189,8 @@ impl Store { Ok(record) } /// Cot 0.7 has no transaction API. A single parameterized CTE commits the CAS, - /// membership (if any), history, audit, and durable job as one PostgreSQL statement. - #[allow(clippy::too_many_arguments)] + /// employee-role grants (if changed), history, audit, and durable job as one + /// PostgreSQL statement. Instance administrators pass the permission check. pub async fn commit( &self, record: &mut WorkspaceRecord, @@ -175,15 +198,16 @@ impl Store { permission: Permission, action: &str, details: Value, - member: Option<(Uuid, Vec)>, + role_permissions: Option>, ) -> Result<()> { let workspace = record.id.to_string(); let actor = actor.to_string(); let document = serde_json::to_string(&record.document)?; let details = details.to_string(); let required = serde_json::to_string(&vec![permission])?; - let member_id = member.as_ref().map(|(id, _)| id.to_string()); - let grants = serde_json::to_string(&member.map(|(_, g)| g).unwrap_or_default())?; + let role_permissions = role_permissions + .map(|grants| serde_json::to_string(&grants)) + .transpose()?; let result = self .db .raw_with( @@ -196,8 +220,7 @@ impl Store { &action, &details, &required, - &member_id, - &grants, + &role_permissions, ], ) .await?; @@ -210,6 +233,57 @@ impl Store { record.revision += 1; Ok(()) } + /// Revision-checked change made by the system (configuration sync or identity linking) + /// rather than by a person with a workspace grant. Never expose this to request input. + pub async fn system_commit( + &self, + record: &mut WorkspaceRecord, + actor: Option, + action: &str, + details: Value, + ) -> Result<()> { + let workspace = record.id.to_string(); + let actor = actor.map(|a| a.to_string()); + let document = serde_json::to_string(&record.document)?; + let details = details.to_string(); + let result = self + .db + .raw_with( + SYSTEM_COMMIT_WORKSPACE, + &[ + &workspace, + &record.revision, + &document, + &actor, + &action, + &details, + ], + ) + .await?; + if result.rows_affected().0 == 0 { + return Err(Error::Conflict( + crate::i18n::text("err_this_team_changed_refresh_before_saving_7f93ad").into(), + )); + } + record.revision += 1; + Ok(()) + } + pub async fn instance_event( + &self, + actor: Option, + action: &str, + details: Value, + ) -> Result<()> { + let actor = actor.map(|a| a.to_string()); + let details = details.to_string(); + self.db + .raw_with( + "INSERT INTO meguri__instance_audit(actor_id,action,details,created_at) VALUES($1,$2,$3,now())", + &[&actor, &action, &details], + ) + .await?; + Ok(()) + } pub async fn audit(&self, workspace: Uuid) -> Result> { let mut names = crate::audit::Names::new(); for person in self.directory(workspace).await? { @@ -379,12 +453,11 @@ impl Store { const COMMIT_WORKSPACE: &str = r#" WITH updated AS ( UPDATE meguri__workspaces SET document=$3,revision=revision+1 - WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL - AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb)) RETURNING * -), member AS ( - INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) - SELECT $1||':'||$8,$1,$8,$9,false FROM updated WHERE $8::text IS NOT NULL - ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions + WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL + AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$4 AND instance_admin) + OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb))) RETURNING * +), roles AS ( + UPDATE meguri__workspace_members SET permissions=$8 WHERE $8::text IS NOT NULL AND NOT is_owner AND workspace_id IN(SELECT id FROM updated) ), history AS ( INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4 FROM updated RETURNING effective_at @@ -396,3 +469,19 @@ WITH updated AS ( INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$4,$5,$6,(SELECT effective_at FROM history) FROM updated "#; + +const SYSTEM_COMMIT_WORKSPACE: &str = r#" +WITH updated AS ( + UPDATE meguri__workspaces SET document=$3,revision=revision+1 + WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL RETURNING * +), history AS ( + INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) + SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4::text FROM updated RETURNING effective_at +), jobs AS ( + INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) + SELECT id,revision,'pending',0,now() FROM updated + ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL +) +INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) +SELECT id,$4::text,$5,$6,(SELECT effective_at FROM history) FROM updated +"#; diff --git a/src/web.rs b/src/web.rs index 6fec4e8..a387d10 100644 --- a/src/web.rs +++ b/src/web.rs @@ -48,9 +48,15 @@ impl App for MeguriApp { ]; for path in [ "/api/session", - "/api/register", "/api/login", "/api/logout", + "/api/oidc/login", + "/api/oidc/callback", + "/api/admin/overview", + "/api/admin/users", + "/api/admin/users/{user}/groups", + "/api/admin/users/{user}/password", + "/api/admin/workspaces/{workspace}", "/api/account", "/api/workspaces", "/api/workspaces/{workspace}", @@ -64,7 +70,7 @@ impl App for MeguriApp { "/api/workspaces/{workspace}/absences", "/api/workspaces/{workspace}/generation-preview", "/api/workspaces/{workspace}/generation", - "/api/workspaces/{workspace}/members", + "/api/workspaces/{workspace}/member-permissions", "/api/workspaces/{workspace}/requests", "/api/workspaces/{workspace}/request-preview", "/api/workspaces/{workspace}/requests/{request}", @@ -271,19 +277,28 @@ async fn dispatch(request: Request) -> Result { crate::i18n::text("err_send_a_json_request_34adc9"), )?; } + let config = crate::access::current()?; let identity = authenticate(&db, &request).await; if path == "/api/session" && method == Method::GET { + let auth = + json!({"oidc":config.oidc.is_some(),"password":config.password_form_available()}); return match identity { Ok(i) => response( - json!({"user":i.user,"csrf":i.csrf,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}), + json!({"user":i.user,"csrf":i.csrf,"auth":auth,"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}), ), Err(Error::Unauthorized) => response( - json!({"user":null,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}), + json!({"user":null,"auth":auth,"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}), ), Err(e) => Err(e), }; } - let anonymous = path == "/api/login" || path == "/api/register"; + if path == "/api/oidc/login" && method == Method::GET { + return oidc_login(&db).await; + } + if path == "/api/oidc/callback" && method == Method::GET { + return oidc_callback(&db, config, &request, &query_string).await; + } + let anonymous = path == "/api/login"; if mutation && !anonymous { let i = identity.as_ref().map_err(|_| Error::Unauthorized)?; if request @@ -314,37 +329,19 @@ async fn dispatch(request: Request) -> Result { struct Credentials { username: String, password: String, - #[serde(default)] - display_name: String, } let credentials: Credentials = decode(body)?; check_login_rate(&credentials.username)?; - if path == "/api/register" { - if !enabled("MEGURI_ALLOW_SIGNUP") { - return Err(Error::Forbidden); - } - service::register( - &db, - credentials.username.clone(), - credentials.display_name, - credentials.password.clone(), - ) - .await?; - } - let (user, token, csrf) = - service::login(&db, credentials.username, credentials.password).await?; - let secure = if origin().starts_with("https://") { - "; Secure" - } else { - "" - }; + let (user, token, csrf) = service::login( + &db, + config.password_login_enabled(), + credentials.username.clone(), + credentials.password, + ) + .await?; + forget_login_attempts(&credentials.username); return response(json!({"user":user,"csrf":csrf}))? - .with_header( - "Set-Cookie", - format!( - "meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}" - ), - ) + .with_header("Set-Cookie", session_cookie(&token)) .into_response() .map_err(|e| Error::Internal(e.to_string())); } @@ -373,14 +370,18 @@ async fn dispatch(request: Request) -> Result { .await? .ok_or(Error::NotFound)?; if let Some(password) = body["password"].as_str().filter(|p| !p.is_empty()) { + ensure( + user.password_hash.is_some(), + crate::i18n::text("err_this_account_signs_in_with_sso_and_has_no_password_6d1f3b"), + )?; let current = body["current_password"] .as_str() .unwrap_or_default() .to_owned(); - let (_, temporary, _) = service::login(&db, user.username.clone(), current).await?; - let hash = service::token_hash(&temporary); - query!(SessionRow,$token_hash==hash).delete(&db.db).await?; - user.password_hash = service::hash_password(password.to_owned()).await?; + service::verify_password(&db, &user.username, current) + .await? + .ok_or(Error::Unauthorized)?; + user.password_hash = Some(service::hash_password(password.to_owned()).await?); } user.display_name = name.into(); if let Some(language) = body.get("language") { @@ -418,10 +419,8 @@ async fn dispatch(request: Request) -> Result { return response(json!({"user":store::user(user)?})); } let parts: Vec<_> = path.trim_matches('/').split('/').collect(); - if parts == ["api", "workspaces"] && method == Method::POST { - return response(serde_json::to_value( - service::create_workspace(&db, actor, text(&body, "name")?).await?, - )?); + if parts.len() >= 3 && parts[1] == "admin" { + return admin_route(&db, config, actor, &parts, &method, body).await; } if parts.len() < 3 || parts[1] != "workspaces" { return Err(Error::NotFound); @@ -475,8 +474,16 @@ async fn dispatch(request: Request) -> Result { } else { json!({"total":0,"issues":[]}) }; + let access = if grants.contains(&Permission::ManageMembers) { + db.workspace_key(workspace) + .await? + .and_then(|key| config.workspaces.iter().find(|w| w.key == key)) + .map(|w| json!({"admin_groups":w.admin_groups,"member_groups":w.member_groups})) + } else { + None + }; return response( - json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}), + json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"instance_admin":identity.user.instance_admin,"access":access,"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}), ); } let action = parts.get(3).copied().unwrap_or_default(); @@ -622,14 +629,13 @@ async fn dispatch(request: Request) -> Result { .await?, )?); } - if action == "members" && method == Method::PUT { + if action == "member-permissions" && method == Method::PUT { return response(serde_json::to_value( - service::set_member( + service::set_member_permissions( &db, actor, workspace, revision(&body)?, - text(&body, "username")?, decode(body["permissions"].clone())?, ) .await?, @@ -717,6 +723,149 @@ async fn dispatch(request: Request) -> Result { Err(Error::NotFound) } +fn session_cookie(token: &str) -> String { + let secure = if origin().starts_with("https://") { + "; Secure" + } else { + "" + }; + format!("meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}") +} + +fn flow_cookie(value: &str, max_age: u32) -> String { + let secure = if origin().starts_with("https://") { + "; Secure" + } else { + "" + }; + format!( + "meguri_oidc={value}; Path=/api/oidc; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}" + ) +} + +fn redirect(location: &str) -> Result { + cot::response::Redirect::new(location.to_owned()) + .into_response() + .map_err(|e| Error::Internal(e.to_string())) +} + +fn login_failure(code: &str, detail: impl std::fmt::Display) -> Result { + let reference = Uuid::new_v4().simple().to_string()[..8].to_owned(); + tracing::warn!(%reference, %code, %detail, "SSO sign-in was not completed"); + redirect(&format!("/?login_error={code}&ref={reference}"))? + .with_header("Set-Cookie", flow_cookie("", 0)) + .into_response() + .map_err(|e| Error::Internal(e.to_string())) +} + +async fn oidc_login(db: &Store) -> Result { + if crate::oidc::settings().is_none() { + return Err(Error::NotFound); + } + match crate::oidc::begin(db).await { + Ok((url, state)) => redirect(&url)? + .with_header("Set-Cookie", flow_cookie(&state, 600)) + .into_response() + .map_err(|e| Error::Internal(e.to_string())), + Err(error) => login_failure("unavailable", error), + } +} + +async fn oidc_callback( + db: &Store, + config: &crate::access::Config, + request: &Request, + query_string: &str, +) -> Result { + use crate::{identity, oidc}; + if oidc::settings().is_none() { + return Err(Error::NotFound); + } + let url = reqwest::Url::parse(&format!("http://localhost/?{query_string}")) + .map_err(|_| Error::NotFound)?; + let params: std::collections::BTreeMap<_, _> = url.query_pairs().into_owned().collect(); + if let Some(error) = params.get("error") { + return login_failure("failed", format!("provider returned {error}")); + } + let (Some(state), Some(code)) = (params.get("state"), params.get("code")) else { + return login_failure("failed", "callback without state or code"); + }; + let cookie_state = request + .headers() + .get("cookie") + .and_then(|h| h.to_str().ok()) + .unwrap_or_default() + .split(';') + .map(str::trim) + .find_map(|c| c.strip_prefix("meguri_oidc=")) + .map(str::to_owned); + let profile = match oidc::complete(db, cookie_state.as_deref(), state, code).await { + Ok(profile) => profile, + Err(oidc::Failure::Unavailable(detail)) => return login_failure("unavailable", detail), + Err(oidc::Failure::Invalid(detail)) => return login_failure("failed", detail), + }; + match identity::sso_sign_in(db, config, profile.clone()).await { + Ok(identity::SsoOutcome::SignedIn { token, .. }) => redirect("/")? + .with_header("Set-Cookie", session_cookie(&token)) + .with_header("Set-Cookie", flow_cookie("", 0)) + .into_response() + .map_err(|e| Error::Internal(e.to_string())), + Ok(identity::SsoOutcome::NoAccess) => login_failure( + "no_access", + format!( + "no workspace or instance role matches the groups {:?} of subject {}", + profile.groups, profile.subject + ), + ), + Err(error) => login_failure("failed", error), + } +} + +async fn admin_route( + db: &Store, + config: &crate::access::Config, + actor: Uuid, + parts: &[&str], + method: &Method, + body: Value, +) -> Result { + use crate::admin; + match (parts, method) { + (["api", "admin", "overview"], &Method::GET) => { + response(admin::overview(db, config, actor).await?) + } + (["api", "admin", "users"], &Method::POST) => { + response(admin::create_user(db, config, actor, decode(body)?).await?) + } + (["api", "admin", "users", user, "groups"], &Method::PUT) => response( + admin::set_user_groups( + db, + config, + actor, + parse_id(user)?, + decode(body["groups"].clone())?, + ) + .await?, + ), + (["api", "admin", "users", user, "password"], &Method::POST) => { + admin::reset_password(db, actor, parse_id(user)?, text(&body, "password")?).await?; + response(json!({"ok":true})) + } + (["api", "admin", "workspaces", workspace], &Method::DELETE) => { + admin::delete_workspace( + db, + config, + actor, + parse_id(workspace)?, + &text(&body, "confirm")?, + ) + .await?; + response(json!({"ok":true})) + } + _ => Err(Error::NotFound), + } +} + fn replacement_boundary( doc: &WorkspaceDocument, schedule: Uuid, @@ -761,19 +910,24 @@ fn decode(value: Value) -> Result { }) } +type Attempts = std::sync::Mutex>; +fn attempts() -> &'static Attempts { + static ATTEMPTS: std::sync::OnceLock = std::sync::OnceLock::new(); + ATTEMPTS.get_or_init(Default::default) +} + +/// A successful sign-in clears the counter; only failed guessing accumulates. +fn forget_login_attempts(username: &str) { + if let Ok(mut attempts) = attempts().lock() { + attempts.remove(&username.trim().to_ascii_lowercase()); + } +} + fn check_login_rate(username: &str) -> Result<()> { - use std::{ - collections::HashMap, - sync::{Mutex, OnceLock}, - time::{Duration, Instant}, - }; - static ATTEMPTS: OnceLock>> = OnceLock::new(); - let mut attempts = ATTEMPTS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| { - Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into()) - })?; + use std::time::{Duration, Instant}; + let mut attempts = attempts().lock().map_err(|_| { + Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into()) + })?; attempts.retain(|_, (at, _)| at.elapsed() < Duration::from_secs(300)); if attempts.len() > 2000 { return Err(Error::Validation( diff --git a/static/app.css b/static/app.css index dc007ee..385a45a 100644 --- a/static/app.css +++ b/static/app.css @@ -194,3 +194,22 @@ input[type=date]:not(.native-date),.date-field{max-width:176px;min-width:0} .map-source-card{border:1px solid var(--line);border-radius:7px;padding:10px;margin:8px 0;background:#f8faf9}.map-source-title{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px}.map-source-title>small{margin-left:auto;color:var(--muted)}.map-source-addresses{display:flex;flex-direction:column;gap:4px;margin-top:7px}.map-source-addresses code{font:10px/1.5 Consolas,monospace;overflow-wrap:anywhere;color:#567565}.map-source-card>button{margin-top:6px;padding:3px 0} .map-resource-fields{padding:0 12px 12px;border-top:1px solid var(--line)}.map-field-hint{display:block;font-size:10px;line-height:1.5;color:var(--muted);margin-bottom:6px}.map-resource-fields .map-reference>small{flex-basis:auto;margin:0;min-width:15px}.map-resource-fields .map-input-field:last-child{margin-bottom:0} + +.login-form .password-login{display:block}.login-form .password-login>label:first-of-type{margin-top:22px}.login-form .sso-button{display:block;width:100%;margin-top:26px;padding:13px;text-align:center;text-decoration:none;border:1px solid var(--green);border-radius:7px;box-sizing:border-box} +.login-divider{display:flex;align-items:center;gap:12px;margin:22px 0 0;color:var(--muted);font-size:12px}.login-divider::before,.login-divider::after{content:"";flex:1;border-top:1px solid var(--line)} +.login-error{display:flex;flex-direction:column;gap:6px;margin-top:22px;padding:12px 14px;border:1px solid #e9c6b9;border-radius:7px;background:#fff3ee;color:#a04e34;font-size:13px;line-height:1.5}.login-error small{font-size:11px;user-select:all;color:#8a5f4f} +.demo-accounts{list-style:none;margin:8px 0;padding:0;display:flex;flex-direction:column;gap:2px}.demo-account{display:flex;align-items:baseline;gap:10px;padding:2px 0;font-size:11px;text-align:left;border:0;background:none}.demo-account code{min-width:56px;font-weight:700}.demo-password{display:block;margin-top:6px}.demo-password code{user-select:all}.demo-note small{display:block;margin-top:6px;color:#8a977f} +a.button{display:inline-block;text-decoration:none;border:1px solid #dbe3de;border-radius:7px;padding:9px 13px;color:inherit}a.button.primary{width:auto;margin-top:12px} +.sidebar nav .instance-link{margin-top:6px;border-top:1px solid var(--line);border-radius:0;padding-top:18px} +.warning-banner{margin:0 0 20px;padding:14px 16px;border:1px solid #e5c9a8;border-radius:8px;background:#fdf4e3;color:#7d5a1d;font-size:13px;line-height:1.5} +.config-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 24px;margin:12px 0}.config-summary>div{min-width:0}.config-summary dt{font-size:11px;color:var(--muted);margin-bottom:3px}.config-summary dd{margin:0;overflow-wrap:anywhere} +.admin-workspace.disabled{background:#faf7f2}.admin-workspace small{display:block;margin-top:3px;overflow-wrap:anywhere}.disabled-note{color:#a17f32} +.admin-user small{display:block;margin-top:3px;overflow-wrap:anywhere}.admin-user .badge,.admin-workspace .badge{white-space:nowrap}.admin-user>button,.admin-workspace>button{white-space:nowrap}small.inline{display:inline} +.padded{padding:16px}.danger-button{color:#a04e34;border-color:#e9c6b9}.danger-button:hover{background:#fff3ee}.danger-note{background:#fff3ee;color:#a04e34} +.role-badge{text-transform:none}.role-badge.admin{background:#e4f1e8;color:#3d7e57} +.permission-list{list-style:none;margin:12px 0;padding:0;display:flex;flex-direction:column;gap:6px}.permission-list li{color:var(--muted)}.permission-list li.on{color:var(--ink)} +.group-picker{border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:12px 0 0}.group-picker legend{font-size:12px;color:var(--muted);padding:0 6px}.group-picker .check{margin:6px 0} +@media(max-width:700px){.config-summary{grid-template-columns:1fr}.admin-user,.admin-workspace{flex-wrap:wrap}.admin-user>.grow,.admin-workspace>.grow{flex-basis:calc(100% - 60px)}} +@media(max-width:700px){.sidebar nav .instance-link{margin:0;border:0;border-radius:6px;padding:6px}.sidebar nav .instance-link>span:not(.nav-icon){position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}} +.admin-user .badge,.admin-workspace .badge{text-transform:none}.admin-user strong{display:inline} +@media(max-width:700px){.sidebar nav .instance-link{padding:6px 2px}.sidebar nav .instance-link .nav-icon{width:16px}} diff --git a/static/app.js b/static/app.js index 77f5cf8..b435c29 100644 --- a/static/app.js +++ b/static/app.js @@ -18,8 +18,8 @@ document.addEventListener('alpine:init', () => { })); Alpine.data('meguri', () => ({ ...generationUi(), - user: null, csrf: '', ready: false, busy: false, error: '', toast: '', allowSignup: false, demo: false, demoLifecycle:null, - loginForm: { username: '', password: '', display_name: '' }, registering: false, + user: null, csrf: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null, + loginForm: { username: '', password: '' }, directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] }, view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false, navigationRestoring:0, navigationEpoch:0, setupOpen:false, scheduleSearch:'', schedulePicker:false, coverQuery:'', requestResult:null, requestError:'', requestLoading:false, requestSequence:0, @@ -51,7 +51,7 @@ document.addEventListener('alpine:init', () => { workingSummary() { const w=this.doc?.working_interval; return w ? w.weekdays.map(d=>[tr('mon_24b2a0'),tr('tue_529541'),tr('wed_23408b'),tr('thu_3593cc'),tr('fri_bbd6e3'),tr('sat_6b782d'),tr('sun_48c98c')][d-1]).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone : ''; }, conditionText(c) { return (c.negate?tr('not_975146'):'')+this.label(c.field)+' '+this.label(c.operation)+' “'+c.value+'”'; }, scheduleTime(id) { return this.doc?.schedules.find(s=>s.id===id)?.handover?.slice(0,5) || '11:00'; }, - get nav(){return [{ id: 'calendar', name: tr('calendar_adab50'), icon: '▦' }, { id: 'requests', name: tr('requests_f7194e'), icon: '⇄' }, { id: 'configuration', name: tr('schedule_setup'), icon: '⊞' }, { id: 'activity', name: tr('activity_81c0d9'), icon: '≡' }, { id: 'settings', name: tr('team_settings_06b774'), icon: '⚙' }];}, + get nav(){return [{ id: 'calendar', name: tr('calendar_adab50'), icon: '▦' }, { id: 'requests', name: tr('requests_f7194e'), icon: '⇄' }, { id: 'configuration', name: tr('schedule_setup'), icon: '⊞' }, { id: 'activity', name: tr('activity_81c0d9'), icon: '≡' }, { id: 'settings', name: tr('team_settings_06b774'), icon: '⚙' }, { id: 'instance', name: tr('access_instance_nav'), icon: '⬢' }];}, get grantOptions(){return [{ id: 'view', name: tr('view_calendar_b702b1') }, { id: 'request_overrides', name: tr('request_replacements_386c5e') }, { id: 'edit_schedules', name: tr('edit_schedules_policies_a02744') }, { id: 'force_overrides', name: tr('apply_without_confirmation_f0350d') }, { id: 'manage_members', name: tr('manage_team_access_db8b53') }, { id: 'manage_integration', name: tr('manage_provider_804901') }, { id: 'view_audit', name: tr('view_audit_history_896487') }];}, get workspace() { return this.detail?.workspace; }, get doc() { return this.workspace?.document; }, @@ -70,6 +70,11 @@ document.addEventListener('alpine:init', () => { }, get needsMe() { return this.pending.filter(r=>r.required_approvers.includes(this.user?.id) && !r.approvals.includes(this.user?.id)); }, get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); }, + get employeeGrantOptions(){return this.grantOptions.filter(g=>!['manage_members','manage_integration'].includes(g.id));}, + canEditUserGroups(u){return !u.sso && !u.bootstrap && u.id!==this.user?.id;}, + async loadAdmin(){this.admin=await this.api('/api/admin/overview');}, + useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';}, + async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}}, get setupView() { return ['configuration','settings'].includes(this.view); }, get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); }, get nextDuty() { const now=Date.now(); return this.calendar.assignments.filter(a=>a.schedule_id===this.scheduleId && a.user_id===this.user?.id && !a.unavailable && Date.parse(a.end)>now).sort((a,b)=>a.start.localeCompare(b.start))[0]; }, @@ -79,10 +84,11 @@ document.addEventListener('alpine:init', () => { get monthTitle() { return new Date((this.startDay || this.today()) + 'T12:00:00Z').toLocaleDateString(this.language, { month: 'long', year: 'numeric', timeZone: 'UTC' }); }, get nowAssignment() { const now = new Date(); return this.calendar.assignments.find(a => !a.unavailable && a.schedule_id === this.scheduleId && new Date(a.start) <= now && now < new Date(a.end)); }, can(permission) { return (this.detail?.permissions || []).includes(permission); }, - person(id) { return this.people.find(p=>p.id===id)?.display_name || this.members.find(m=>m.user.id===id)?.user.display_name || tr('unknown_employee_f68692'); }, + personById(id) { return this.people.find(p=>p.id===id) || this.people.find(p=>p.aliases?.includes(id)); }, + person(id) { return this.personById(id)?.display_name || this.members.find(m=>m.user.id===id)?.user.display_name || tr('unknown_employee_f68692'); }, shortName(id) { return this.person(id).split(' ')[0]; }, initials(id) { return this.person(id).split(' ').map(s => s[0]).slice(0, 2).join(''); }, - color(id) { return ['teal', 'purple', 'amber', 'blue', 'rose'][Math.max(0, this.people.findIndex(p => p.id === id)) % 5]; }, + color(id) { return ['teal', 'purple', 'amber', 'blue', 'rose'][Math.max(0, this.people.findIndex(p => p.id === id || p.aliases?.includes(id))) % 5]; }, scheduleName(id) { return this.doc?.schedules.find(s => s.id === id)?.name || tr('archived_schedule_40f4da'); }, today() { return this.dayKey(new Date(), this.doc?.timezone || 'UTC'); }, dayKey(date, zone = 'UTC') { const p = Object.fromEntries(new Intl.DateTimeFormat('en', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(p => [p.type, p.value])); return `${p.year}-${p.month}-${p.day}`; }, @@ -109,21 +115,23 @@ document.addEventListener('alpine:init', () => { writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);}, menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;}, async changeWorkspace(){this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.run(()=>this.loadWorkspace());}, - async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();this.modal='';this.error='';this.view=route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}}, + async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}}, async init() { this.$watch('user',user=>{Alpine.store('locale').language=user?.language || I18n.browser;}); this.$watch('form',()=>{if(this.modal==='absence')this.absenceChanged();if(this.modal==='request')this.requestChanged();if(['variable','generation-rule'].includes(this.modal))this.generationChanged();}); this.$watch('draftPeople',()=>{if(this.modal==='absence')this.absenceChanged();}); this.$watch('generationDraft',()=>{if(this.generationDraft && this.can('edit_schedules'))this.generationChanged();}); this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}}); - this.$watch('view',v=>{if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();}); + this.$watch('view',v=>{if(v==='instance'){if(this.user?.instance_admin)this.run(()=>this.loadAdmin());}if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();}); + this.consumeLoginError(); const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation()); - await this.run(async () => { const session = await this.api('/api/session'); this.user = session.user; this.csrf = session.csrf || ''; this.allowSignup = session.allow_signup; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) await this.loadWorkspaces(); }); + await this.run(async () => { const session = await this.api('/api/session'); this.user = session.user; this.csrf = session.csrf || ''; this.auth = session.auth || {oidc:false,password:true}; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) { if(this.view==='instance' && !this.user.instance_admin)this.view='calendar'; await this.loadWorkspaces(); if(this.view==='instance')await this.loadAdmin(); } }); if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft(); setInterval(() => { if (this.user && this.workspace && this.view!=='configuration' && !this.modal && !this.busy && !this.draggedRoute) this.run(() => this.loadWorkspace()); }, 20000); }, - async signIn() { await this.run(async () => { const s = await this.api(this.registering ? '/api/register' : '/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); }); }, - async signOut() { await this.run(async () => { await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); }, + consumeLoginError(){const url=new URL(location.href),code=url.searchParams.get('login_error');if(code===null)return;const ref=url.searchParams.get('ref')||'';this.loginError={code:['no_access','failed','unavailable'].includes(code)?code:'failed',ref:ref.slice(0,64)};url.searchParams.delete('login_error');url.searchParams.delete('ref');history.replaceState({},'',url.pathname+url.search+url.hash);}, + async signIn() { await this.run(async () => { this.loginError=null; const s = await this.api('/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); if(this.view==='instance'){if(this.user.instance_admin)await this.loadAdmin();else this.view='calendar';} }); }, + async signOut() { await this.run(async () => { await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); }, async loadWorkspaces() { this.directoryState = await this.api('/api/workspaces'); if (!this.directoryState.workspaces.some(t => t.id === this.selected)) this.selected = (this.demo && ['alice','bob','carol'].includes(this.user?.username) ? this.directoryState.workspaces.find(t=>t.name===(this.user.username==='carol'?'Customer Support':'Acme Engineering'))?.id : '') || this.directoryState.workspaces[0]?.id || ''; if (this.selected) await this.loadWorkspace(); else this.detail = null; this.writeNavigation(true); }, async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); if(this.view==='configuration')this.loadGenerationDraft(); }, async loadCalendar() { const workspace=this.workspace.id;const calendar=await this.api(this.endpoint(`/calendar?from=${this.startDay}&days=28&schedule=${this.scheduleId}`));if(this.selected===workspace)this.calendar=calendar; }, @@ -138,8 +146,8 @@ document.addEventListener('alpine:init', () => { if (type === 'priority') this.form = { id: crypto.randomUUID(), name: '', type: 'priority', handover:'11:00:00', participants: this.members.slice(0,2).map(m=>m.user.id), fallback: this.doc.schedules.find(s => s.type === 'rotation')?.id, ...data }; if (type === 'absence') {this.absenceResult=null;this.absenceError='';this.absenceRevision=this.workspace.revision;this.showAllAbsence=false;this.form = { id: crypto.randomUUID(), user_id: this.user.id, start: this.today(), end: this.today(), ranges:[],force:false,reason:'',...data };this.absenceChanged();} if (type === 'holiday') this.form = { date: this.today(), name: '', country: this.user.country || 'CY', ...data }; - if (type === 'member') this.form = { username: '', permissions: ['view', 'request_overrides'], ...data }; - if (type === 'workspace') this.form = { name: '' }; + if (type === 'member-permissions') this.form = { permissions: [...(this.doc.member_permissions || ['view','request_overrides','edit_schedules'])] }; + if (type === 'admin-user') this.form = { username: '', display_name: '', password: '', email: '', groups: [] }; if (type === 'account') this.form = { language:this.user.language || '', display_name: this.user.display_name, country: this.user.country || 'CY', timezone:this.user.timezone || '', current_password: '', password: '' }; if (type === 'policy') this.form = { id: crypto.randomUUID(), name: '', steps: [{ delay_minutes: 0, condition: 'if-not-acked', target: `schedule:${this.doc.schedules[0]?.id}` }], repeat: { count: 0, wait_minutes: 0, reset_recipient_states: false, close_alert_after_all: false }, ...data }; if (type === 'policy' && data.steps) this.form.steps = data.steps.map(s => ({ ...s, target: s.recipient.id ? `${s.recipient.type}:${s.recipient.id}` : s.recipient.type })); @@ -258,13 +266,16 @@ document.addEventListener('alpine:init', () => { if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;} await this.run(async () => { const f = copy(this.form); const kind = this.modal; const revision = this.workspace?.revision; - if (kind === 'workspace') { const created=await this.api('/api/workspaces','POST',f);this.selected=created.id; } - else if (kind === 'account') { const r = await this.api('/api/account', 'PUT', f); this.user = r.user; } + if (kind === 'account') { const r = await this.api('/api/account', 'PUT', f); this.user = r.user; } else if(kind==='working') await this.api(this.endpoint('/working-interval'),'PUT',{revision,interval:{...f,weekdays:f.weekdays.map(Number),start:timeValue(f.start),end:timeValue(f.end)}}); else if(kind==='holiday-import') { if(!this.canImport)throw new Error(tr('preview_the_csv_and_fix_any_errors_before_importing_2ccc4c'));await this.api(this.endpoint('/holiday-import'),'POST',{revision:this.csvResult.revision,csv:this.csvText}); } else if(kind==='remove-schedule') { if(!this.canRemoveSchedule)throw new Error(tr('resolve_the_listed_dependencies_before_removing_this_fa1445'));await this.api(this.endpoint('/schedules/'+f.id+'/remove'),'POST',{revision:this.removal.revision,redirect_to:f.redirect_to || null}); } else if(kind==='absence') {if(!this.canSaveAbsence)throw new Error(tr('wait_for_a_valid_absence_preview_before_saving_c743bf'));await this.api(this.endpoint('/absences'),'POST',this.absencePayload());} - else if (kind === 'member') await this.api(this.endpoint('/members'), 'PUT', { revision, ...f }); + else if (kind === 'member-permissions') await this.api(this.endpoint('/member-permissions'), 'PUT', { revision, permissions: ['view', ...f.permissions.filter(p=>p!=='view')] }); + else if (kind === 'admin-user') { const body={username:f.username,display_name:f.display_name,password:f.password,groups:f.groups};if(f.email.trim())body.email=f.email.trim();await this.api('/api/admin/users','POST',body);await this.loadAdmin(); } + else if (kind === 'admin-groups') { await this.api(`/api/admin/users/${f.id}/groups`,'PUT',{groups:f.groups});await this.loadAdmin(); } + else if (kind === 'admin-password') { await this.api(`/api/admin/users/${f.id}/password`,'POST',{password:f.password});await this.loadAdmin(); } + else if (kind === 'admin-delete') { await this.api(`/api/admin/workspaces/${f.id}`,'DELETE',{confirm:f.confirm});await this.loadAdmin(); } else if (kind === 'integration') await this.api(this.endpoint('/integration'), 'PUT', { revision, integration: f.provider === 'mock' ? { provider: 'mock' } : {...f,users:Object.fromEntries(Object.entries(f.users).filter(([id,value])=>value?.trim()).map(([id,value])=>[id,value.trim()]))} }); else if (kind === 'force') await this.api(this.endpoint(`/requests/${f.id}`), 'POST', { revision, action: 'force', reason: f.reason }); else { @@ -289,8 +300,9 @@ document.addEventListener('alpine:init', () => { await this.api(this.endpoint('/config'), 'PUT', { revision, document: doc }); } await this.loadWorkspaces(); this.modal = ''; this.draftPeople=[]; - }, force ? tr('replacement_applied_publication_is_queued_89e283') : tr('changes_saved_5ffbaf')); + }, force ? tr('replacement_applied_publication_is_queued_89e283') : this.savedMessage(this.modal)); }, + savedMessage(kind){const key={'admin-user':'access_user_created','admin-groups':'access_groups_saved','admin-password':'access_password_reset_done','admin-delete':'access_workspace_deleted'}[kind];return tr(key||'changes_saved_5ffbaf');}, async decide(request, action) { await this.run(async () => { await this.api(this.endpoint(`/requests/${request.id}`), 'POST', { revision: this.workspace.revision, action }); await this.loadWorkspace(); }, tr('request_updated_c52ad2')); }, async removeItem(collection, id) { if(collection==='schedules') { await this.run(async()=>{const result=await this.api(this.endpoint('/schedules/'+id+'/removal'));this.open('remove-schedule',{id,redirect_to:''});this.removal=result;});return; } diff --git a/static/index.html b/static/index.html index e52a5b0..b5bfeef 100644 --- a/static/index.html +++ b/static/index.html @@ -41,22 +41,31 @@ -