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

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

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

No files matched your search

+10 -1
View File
@@ -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
+2 -2
View File
@@ -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
+4 -3
View File
@@ -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).
Generated
+460 -9
View File
@@ -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"
+4
View File
@@ -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"
+7 -7
View File
@@ -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.
+75 -1
View File
@@ -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 <issuer>/.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
+63
View File
@@ -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 <issuer>/.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
+8
View File
@@ -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
+18 -8
View File
@@ -6,13 +6,13 @@ A modular Rust application serves the web/API and runs a durable publication wor
## Persistence and concurrency
Users, sessions, workspaces, memberships, audit, history, provider checkpoints, and sync jobs are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types.
Users, sessions, external identities, workspaces, memberships, audit, history, provider checkpoints, sync jobs, OIDC flows, instance state and instance audit are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types.
Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization, then writes membership, history, audit, and the durable sync request atomically. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state.
Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization (a workspace grant, or instance administrator), then writes employee-role grants when they changed, history, audit, and the durable sync request atomically. `Store::system_commit` is the same statement without a grant check for configuration sync and identity linking; it never takes request input directly. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state.
The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain queued when older runs succeed or fail. Jobs retry with bounded backoff; fifteen-minute reconciliation extends the horizon and detects drift. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout.
Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. A JSM destination can have only one local workspace binding. Never edit applied migrations.
Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The process that owns migrations (the `migrate` role, or a single `all` process without the skip flag) also synchronizes the instance configuration; replicas only read it. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. Migration 9 makes passwords optional and adds user email, groups, instance-administrator and bootstrap flags, workspace key and disabled timestamp, a unique `(issuer, subject)` index, `instance_state`, `instance_audit` and `oidc_flows`. A JSM destination can have only one local workspace binding. Never edit applied migrations.
## Scheduling model
@@ -28,7 +28,17 @@ The engine receives explicit state and time bounds. It must produce the same res
## Identity and permissions
Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership. External identity linking is a future adapter, never a replacement for internal user IDs.
Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS; a session lasts 12 hours. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership; instance administrators pass the permission check through the `users.instance_admin` flag, in `Store::permissions`/`owner` and inside the commit statement itself. Disabled and demo-expired workspaces are not found by `Store::workspace`. External identities bind `(issuer, subject)` to an internal user ID and never replace it.
### Instance access modules
- `access` is pure: configuration types and strict validation, `resolve_access(config, groups, bootstrap)` (exact group match, administrator beats employee), claim extraction with literal and dotted paths, and bootstrap password derivation (HMAC-SHA256 over `meguri-bootstrap:{username}:{UTC YYYYMMDDHH}` with a database-held secret, first 20 bytes base32). The loaded configuration is installed process-wide for request handlers; service functions take it as an argument.
- `identity` owns every write that turns configuration or provider data into state: `sync_config`, `ensure_workspace`, `apply_access`, bootstrap accounts, `sso_sign_in` and `link_contacts`. Cot has no transactions, so each step is an idempotent statement and concurrent replicas converge; `preflight` rejects configuration-only problems (listing no workspaces while enabled ones exist, ambiguous adoption by name, a bootstrap name owned by an SSO account) before the first write. Sync order: workspaces (create/adopt/enable/rename), disable unlisted workspaces (their sync job and memberships are removed in the same statement), bootstrap accounts, then `reconcile_all`.
- `apply_access` is the single place that turns groups into access. One CTE stores the user's groups and instance-administrator flag and rewrites their `workspace_members` rows: rows outside the desired set are deleted, administrators get every permission, employees get the workspace document's `member_permissions` read in the same statement. SSO sign-in, the administrator's password-user editor and startup reconciliation all end here. Membership rows are a materialized view of groups; nothing grants a row any other way.
- `oidc` implements discovery (cached ten minutes, issuer must match), JWKS (cached, refreshed at most every 15 seconds for an unknown `kid`), PKCE, the one-time `oidc_flows` record, code exchange with client-secret basic authentication, and RS256 token verification (signature, issuer, audience, expiry, nonce, authorized party). It returns a trusted profile; `identity::sso_sign_in` decides what the profile may do.
- `admin` implements the instance administrator's operations (overview, password users, group edits, password reset, workspace deletion) and writes `instance_audit`. Every function re-checks `instance_admin`; the web layer only routes.
The employee permission set lives in `WorkspaceDocument.member_permissions` (default `view`, `edit_schedules`, `request_overrides`). Changing it is an ordinary revision-checked commit that also updates every employee membership row, so the set applies at once. Because a commit's authorization is evaluated in SQL against `workspace_members`, those rows hold the effective permissions.
Replacement preview is a read-only service using the same engine, revision, participant and permission checks as submission. Calendar actions use server-generated interval boundaries; inclusive range inputs are converted through schedule handovers on the server. Volunteering changes the initiator, not the approval requirements. Comments remain in the backward-compatible reason/force_reason fields but are optional action annotations. Current-shift clipping uses one explicit server timestamp per validation/application.
@@ -52,9 +62,9 @@ Random recipients and escalation repeats require JSM Premium/Enterprise. Provide
JSM documents `repeat.waitInterval=0` as disabling repeats even when count is nonzero; the reference Terraform uses this combination. Enabling repeats requires a positive interval. A disabled local count maps to provider count 1 / interval 0 because the provider count minimum is 1. The reference's immediate step uses delay 0; verify it in live acceptance because the published schema lists a minimum of 1. Never silently change immediate notification to a one-minute delay.
## Scheduling metadata and import boundaries
WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented or linked through unverified email. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots.
WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented. At SSO sign-in `identity::link_contacts` merges a contact whose email equals the signed-in user's into that user (the contact ID is rewritten across the current document by exact string replacement, kept as `Person.aliases`, and resolved by audit and the UI for historical snapshots) through `system_commit` with `person.linked` audit, only in workspaces where the user is already a member; linking never creates access. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots.
Workspace creation atomically creates its document, owner membership, initial history, audit and sync request. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration.
Workspaces are created only by configuration sync, which atomically creates the document, initial history, audit and sync request with its configured `key` and no members; memberships appear when users are reconciled. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration.
Migration 3 upgrades old JSON snapshots without changing old handover boundaries or global-holiday meaning and introduces workspace working intervals. Migration 4 adds user country (CY) and an optional display timezone (null follows the browser). Current scheduling state copies the workspace timezone; historical documents retain the timezone effective at their revision. Country-profile changes propagate as dated workspace revisions, while display-timezone preferences remain outside the engine.
@@ -64,7 +74,7 @@ Email-only recipients resolve through [Jira user search](https://developer.atlas
`demo_runtime` chooses the database before Cot migrations. A separate `public.meguri_runtime_guard` records last startup mode; proof and reset recheck it under a PostgreSQL advisory transaction lock. Empty-DB claiming checks user relations. Reset enumerates only Meguri application tables, uses no CASCADE, and preserves migration/provenance metadata. Distinct application names detect another active client. A protected configured DB triggers a separate database and an immutable process notice surfaced by `/api/session`; no credentials are exposed. Normal startup marks its database normal and never schedules cleanup.
Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer refreshes expired seed workspaces through the ordinary services; only the five baseline account identities are permanent during a run. External provider writes are disabled in demo at both configuration and dispatch boundaries.
Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer runs the ordinary configuration sync against the built-in demo configuration (`src/demo-config.yaml`, the production format), recreates expired seed workspaces and reseeds them through the ordinary services; the six seeded accounts (`admin` bootstrap administrator, `alice`, `bob`, `carol`, `dave`, `erin`) are pinned and created through the same administrator operations as in production, with groups. They share one random password per process start, kept in the process-wide demo notice and published by `/api/session`; accounts created later by an administrator expire. External provider writes are disabled in demo at both configuration and dispatch boundaries.
## Presentation catalog
@@ -101,7 +111,7 @@ Generation rule results use the actual authorized server expansion, including sc
## Workspace aggregate
`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores per-user grants and ownership; there is no parent entity. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams.
`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores a user's effective permissions and administrator flag, derived from groups; there is no parent entity. `key` links the row to the configuration and `disabled_at` hides it. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams.
Variable list summaries render nested nodes by type. Object entries prefer a nonempty text `name` or `title`; otherwise they show a bounded key/count summary. People resolve through the scoped directory. Summaries never read a container’s null scalar slot as its content.
+52 -5
View File
@@ -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.
+15 -11
View File
@@ -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_<stable fingerprint>_<slot>` 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.
+24 -6
View File
@@ -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.
+14 -4
View File
@@ -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.
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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')."
+535
View File
@@ -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<OidcConfig>,
#[serde(default)]
pub instance_admin_groups: Vec<String>,
#[serde(default)]
pub workspaces: Vec<WorkspaceConfig>,
}
#[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<BootstrapConfig>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BootstrapConfig {
pub usernames: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupSource {
IdToken,
Userinfo,
}
fn default_scopes() -> Vec<String> {
vec!["openid".into(), "profile".into(), "email".into()]
}
fn default_groups_claim() -> String {
"groups".into()
}
fn default_group_sources() -> Vec<GroupSource> {
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<String>,
/// 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<GroupSource>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
pub key: String,
pub name: String,
#[serde(default)]
pub admin_groups: Vec<String>,
#[serde(default)]
pub member_groups: Vec<String>,
}
#[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<String, Role>,
}
impl Access {
pub fn is_empty(&self) -> bool {
!self.instance_admin && self.workspaces.is_empty()
}
}
fn invalid(message: impl Into<String>) -> 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<Self> {
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<String> {
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<Config> = 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<Vec<String>> {
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<Utc>) -> String {
let mut mac = <Hmac<Sha256> 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<String> {
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());
}
}
+315
View File
@@ -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<BTreeSet<String>> {
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<Value> {
require_instance_admin(db, actor).await?;
let mut members: BTreeMap<String, usize> = BTreeMap::new();
for member in WorkspaceMemberRow::objects().all(&db.db).await? {
*members.entry(member.workspace_id).or_default() += 1;
}
let mut jobs: BTreeMap<String, SyncJobRow> = 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::<crate::domain::WorkspaceDocument>(&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<String>) -> Result<Vec<String>> {
groups.sort();
groups.dedup();
let known: BTreeSet<String> = 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<String>,
#[serde(default)]
pub groups: Vec<String>,
}
/// 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<Uuid>,
input: NewUser,
) -> Result<Value> {
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<Value> {
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<UserRow> {
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<String>,
) -> Result<Value> {
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::<crate::domain::WorkspaceDocument>(&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
}
+21
View File
@@ -8,6 +8,9 @@ pub type Names = BTreeMap<String, String>;
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");
}
}
+24
View File
@@ -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]
+175 -72
View File
@@ -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,87 +227,211 @@ pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variab
)
}
pub async fn seed(db: &Store) -> 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(),
)
/// 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::<usize>().ok())
.map_or(0, |n| n.min(20))
}
pub fn config() -> Result<crate::access::Config> {
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")],
});
}
config.validate()?;
Ok(config)
}
/// Accounts shown on the demonstration login screen.
pub fn accounts() -> Vec<crate::demo_runtime::DemoAccount> {
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<Uuid> {
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)
}
};
users.push(user);
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;
}
for user in &users {
db.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING",
&[&user.id.to_string()],
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 alice = users[0].id;
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",
&[&id.to_string()],
)
.await?;
users.push(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() {
let Some(workspace) = identity::find_workspace(db, key).await? else {
continue;
};
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?
.rows_affected()
.0
> 0;
if !claimed {
continue;
}
let candidate = store::record(row)?;
if candidate.document.name == workspace_name
&& db.owner(candidate.id, alice).await.is_ok()
{
existing = Some(candidate);
break;
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);
}
}
if existing.is_some() {
continue;
Ok(())
}
let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?;
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,
@@ -321,22 +446,6 @@ pub async fn seed(db: &Store) -> Result<()> {
)
.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)?
@@ -346,11 +455,12 @@ pub async fn seed(db: &Store) -> Result<()> {
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, 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),
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,
@@ -362,12 +472,5 @@ pub async fn seed(db: &Store) -> Result<()> {
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
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING",
&[&workspace.id.to_string()],
)
.await?;
}
Ok(())
}
+10 -1
View File
@@ -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<DemoAccount>,
}
static NOTICE: OnceLock<Notice> = 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?;
+22
View File
@@ -31,6 +31,14 @@ impl Permission {
pub fn member() -> Vec<Self> {
vec![Self::View, Self::RequestOverrides]
}
/// Default employee role: calendar access, schedule edits, and approved replacements.
pub fn employee_default() -> Vec<Self> {
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<String>,
#[serde(default)]
pub email: Option<String>,
#[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<Permission>,
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<String>,
pub country: Option<String>,
pub user_id: Option<Uuid>,
/// 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<Uuid>,
}
#[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<Permission>,
#[serde(default)]
pub people: Vec<Person>,
#[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![],
+1
View File
@@ -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();
+1
View File
@@ -624,6 +624,7 @@ fn recipient(
display_name: email.clone(),
email: Some(email),
country: Some("CY".into()),
aliases: vec![],
});
Ok(id)
}
+1
View File
@@ -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![],
});
}
}
+975
View File
@@ -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<String>) -> Error {
Error::Validation(format!("Invalid configuration: {}", message.into()))
}
pub fn normalize_email(value: &str) -> Option<String> {
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<String>) -> 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<bool> {
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<WorkspaceChange>,
}
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<Vec<WorkspaceRow>> {
Ok(WorkspaceRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|r| r.demo_deleted_at.is_none())
.collect())
}
fn record_of(row: &WorkspaceRow) -> Result<WorkspaceRecord> {
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<EnsuredWorkspace> {
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<Option<WorkspaceRecord>> {
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<bool> {
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<Vec<String>> {
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<Vec<String>>,
) -> Result<Access> {
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<usize> {
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<Vec<u8>> {
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<bool> {
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<Vec<String>> {
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<String>,
pub adopted: Vec<String>,
pub enabled: Vec<String>,
pub renamed: Vec<String>,
pub disabled: Vec<String>,
pub bootstrap_rotated: Vec<String>,
pub bootstrap_released: Vec<String>,
/// 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<Utc>,
/// 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<SyncReport> {
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<String>,
pub username_hint: Option<String>,
pub display_name: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug)]
pub enum SsoOutcome {
/// The provider's groups map to nothing; no account is created or kept enabled.
NoAccess,
SignedIn {
user: Box<User>,
token: String,
csrf: String,
},
}
fn username_candidates(profile: &SsoProfile) -> Vec<String> {
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::<String>()
.trim_matches('-')
.chars()
.take(56)
.collect()
};
let mut bases: Vec<String> = [
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<Option<String>> {
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<String> {
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<SsoOutcome> {
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<usize> = 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))
);
}
}
+4
View File
@@ -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;
+64 -2
View File
@@ -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<Config, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
tracing_subscriber::fmt()
@@ -22,6 +40,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<Vec<_>>()
.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<dyn std::error::Error>> {
} 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") {
+2
View File
@@ -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,
];
+27
View File
@@ -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(())
}
+39 -1
View File
@@ -14,8 +14,14 @@ pub struct UserRow {
pub display_name: String,
pub country: String,
pub timezone: Option<String>,
pub password_hash: String,
/// None for accounts that sign in only through SSO or whose bootstrap access was removed.
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
pub email: Option<String>,
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<Utc>,
/// Stable identifier from the instance configuration; None for legacy workspaces.
pub key: Option<String>,
pub disabled_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
@@ -112,3 +121,32 @@ pub struct ProviderObjectRow {
pub content: String,
pub updated_at: DateTime<Utc>,
}
#[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<Utc>,
}
#[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<i64>,
pub actor_id: Option<String>,
pub action: String,
pub details: String,
pub created_at: DateTime<Utc>,
}
+348
View File
@@ -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<Settings> = 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<Failure> for Error {
fn from(failure: Failure) -> Self {
match failure {
Failure::Unavailable(m) | Failure::Invalid(m) => Error::Provider(m),
}
}
}
type Outcome<T> = std::result::Result<T, Failure>;
#[derive(Debug, Clone, Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
#[serde(default)]
userinfo_endpoint: Option<String>,
}
#[derive(Default)]
struct Cache {
discovery: Option<(Instant, Discovery)>,
jwks: Option<(Instant, JwkSet)>,
}
static CACHE: tokio::sync::Mutex<Cache> = 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<reqwest::Client> = 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<T: serde::de::DeserializeOwned>(
what: &str,
request: reqwest::RequestBuilder,
) -> Outcome<T> {
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<Discovery> {
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<DecodingKey> {
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<now()")
.await?;
OidcFlowRow {
state_hash: service::token_hash(&state),
nonce: nonce.clone(),
verifier: verifier.clone(),
expires_at: Utc::now() + Duration::minutes(10),
}
.insert(&db.db)
.await?;
let mut url = reqwest::Url::parse(&found.authorization_endpoint)
.map_err(|e| Failure::Invalid(e.to_string()))?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &settings.config.client_id)
.append_pair("redirect_uri", &settings.redirect_uri)
.append_pair("scope", &settings.config.scopes.join(" "))
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair(
"code_challenge",
&URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())),
)
.append_pair("code_challenge_method", "S256");
Ok((url.to_string(), state))
}
#[derive(Deserialize)]
struct Tokens {
id_token: String,
access_token: Option<String>,
}
/// 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<SsoProfile, Failure> {
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<Value> {
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::<Value>(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::<Value>(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<Value> {
let (endpoint, token) = (found.userinfo_endpoint.as_ref()?, access_token?);
get_json::<Value>("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<SsoProfile> {
let path = &settings.config.groups_claim;
let mut info: Option<Value> = 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<String> {
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(),
})
}
+66 -106
View File
@@ -38,57 +38,13 @@ pub async fn hash_password(password: String) -> Result<String> {
.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<User> {
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<Option<UserRow>> {
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<bool> {
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);
.map_err(|e| Error::Internal(e.to_string()))
}
let row = row.unwrap();
let user = store::user(row)?;
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<WorkspaceRecord> {
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<Permission>,
mut permissions: Vec<Permission>,
) -> Result<WorkspaceRecord> {
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<Value> {
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<Value> {
.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 *
+103 -14
View File
@@ -35,10 +35,13 @@ pub fn user(row: UserRow) -> Result<User> {
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<WorkspaceRecord> {
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<bool> {
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<Option<String>> {
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<Vec<Permission>> {
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<Permission>)>,
role_permissions: Option<Vec<Permission>>,
) -> 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<Uuid>,
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<Uuid>,
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<Vec<Value>> {
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
"#;
+204 -50
View File
@@ -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<Response> {
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<Response> {
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(
let (user, token, csrf) = service::login(
&db,
config.password_login_enabled(),
credentials.username.clone(),
credentials.display_name,
credentials.password.clone(),
credentials.password,
)
.await?;
}
let (user, token, csrf) =
service::login(&db, credentials.username, credentials.password).await?;
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
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<Response> {
.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<Response> {
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<Response> {
} 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<Response> {
.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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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,17 +910,22 @@ fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
})
}
type Attempts = std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, u16)>>;
fn attempts() -> &'static Attempts {
static ATTEMPTS: std::sync::OnceLock<Attempts> = 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<Mutex<HashMap<String, (Instant, u16)>>> = OnceLock::new();
let mut attempts = ATTEMPTS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|_| {
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));
+19
View File
@@ -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}}
+28 -16
View File
@@ -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; }
+171 -43
View File
@@ -41,22 +41,31 @@
</div>
<span class="quiet"><span x-text="t('plan_here_stay_in_sync_everywhere_70e528')"></span></span>
</div>
<form class="login-form" @submit.prevent="signIn">
<div class="login-form">
<span class="eyebrow"><span x-text="t('your_team_s_home_for_on_call_66a895')"></span></span>
<h2 x-text="registering ? t('create_your_account_4046b5') : t('welcome_back_b80783')"></h2>
<h2 x-text="t('welcome_back_b80783')"></h2>
<p class="muted"><span x-text="t('make_space_for_a_smoother_handover_44adaf')"></span></p>
<div class="login-error" role="alert" x-cloak x-show="loginError"><span x-text="loginError ? t('access_login_error_'+loginError.code) : ''"></span><small x-show="loginError?.ref" x-text="loginError?.ref ? t('access_support_reference',{ref:loginError.ref}) : ''"></small></div>
<a class="button primary sso-button" href="/api/oidc/login" x-cloak x-show="auth.oidc"><span x-text="t('access_sso_button')"></span></a>
<div class="login-divider" x-cloak x-show="auth.oidc && auth.password"><span x-text="t('access_or_divider')"></span></div>
<form class="password-login" @submit.prevent="signIn" x-cloak x-show="auth.password">
<label><span x-text="t('username_84c290')"></span><input autocomplete="username" x-model="loginForm.username" required maxlength="64">
</label>
<label x-show="registering"><span x-text="t('display_name_c7874a')"></span><input x-model="loginForm.display_name" :required="registering">
<label><span x-text="t('password_8be3c9')"></span><input type="password" autocomplete="current-password" x-model="loginForm.password" required>
</label>
<label><span x-text="t('password_8be3c9')"></span><input type="password" :autocomplete="registering ? 'new-password' : 'current-password'" x-model="loginForm.password" required :minlength="registering ? 12 : 1">
</label>
<button class="primary" :disabled="busy" x-text="registering ? t('create_account_aaf374') : t('sign_in_ada2e9')">
<button class="primary" :disabled="busy" x-text="t('sign_in_ada2e9')">
</button>
<button type="button" class="text" x-show="allowSignup" @click="registering=!registering" x-text="registering ? t('already_registered_sign_in_0df05c') : t('new_here_create_an_account_2b2c3f')">
</button>
<div class="demo-note" x-show="demo && !registering"><strong><span x-text="t('try_the_demo_38cfaf')"></span></strong><dl><div><dt><span x-text="t('alice_522b27')"></span></dt><dd><span x-text="t('lead_both_workspaces_variables_rules_ed9a19')"></span></dd></div><div><dt><span x-text="t('bob_48181a')"></span></dt><dd><span x-text="t('engineering_weekly_on_call_swaps_de49e8')"></span></dd></div><div><dt><span x-text="t('carol_28b92b')"></span></dt><dd><span x-text="t('support_daily_shifts_approvals_c9a0d8')"></span></dd></div><div><dt x-text="t('demo_extra_users')"></dt><dd x-text="t('demo_extra_users_hint')"></dd></div></dl><span><span x-text="t('password_for_all_2f1a67')"></span> <code><span x-text="t('meguri_demo_only_0c7b5d')"></span></code></span></div>
</form>
<p class="info-note" x-cloak x-show="!auth.password && !auth.oidc"><span x-text="t('access_no_sign_in_method')"></span></p>
<div class="demo-note" x-cloak x-show="demo && demoLifecycle?.accounts?.length"><strong><span x-text="t('access_demo_accounts')"></span></strong>
<ul class="demo-accounts">
<template x-for="account in demoLifecycle?.accounts || []" :key="account.username">
<li><button type="button" class="text demo-account" :data-demo-account="account.username" :aria-label="t('access_demo_use_account',{username:account.username})" @click="useDemoAccount(account)"><code x-text="account.username"></code><span x-text="t('access_role_'+account.role)"></span></button></li>
</template>
</ul>
<span class="demo-password"><span x-text="t('access_demo_password')"></span> <code data-demo-password x-text="demoLifecycle?.password"></code> <button type="button" class="text" @click="copyDemoPassword()"><span x-text="t('access_demo_copy_password')"></span></button></span>
<small x-text="t('access_demo_restart_hint')"></small></div>
</div>
</main>
<div class="shell" :inert="!!modal" x-cloak x-show="ready && user">
<aside class="sidebar">
@@ -83,6 +92,7 @@
</template>
<button class="setup-toggle" :aria-label="t('setup_cdd7bb')" :class="{active:setupView}" @click="setupOpen=!setupOpen" :aria-expanded="setupOpen"><span class="nav-icon"><span x-text="t('symbol_be22a3')"></span></span><span><span x-text="t('setup_cdd7bb')"></span></span><span class="grow"></span><span x-text="setupOpen?'−':'+'"></span></button>
<div class="setup-nav" x-show="setupOpen"><template x-for="item in nav.filter(n=>['configuration','settings'].includes(n.id))" :key="item.id"><a :href="menuHref(item.id)" :aria-current="view===item.id?'page':null" :class="{active:view===item.id}" @click="menuClick($event,item.id)" x-text="item.name"></a></template></div>
<a class="instance-link" x-cloak x-show="user?.instance_admin" :href="menuHref('instance')" :aria-current="view==='instance'?'page':null" :class="{active:view==='instance'}" @click="menuClick($event,'instance')"><span aria-hidden="true" class="nav-icon" x-text="nav.find(n=>n.id==='instance').icon"></span><span x-text="nav.find(n=>n.id==='instance').name"></span></a>
</nav>
<div class="sidebar-bottom">
@@ -115,21 +125,79 @@
</span>
<div class="top-actions">
<span class="display-zone" x-text="t('your_time_31bdbf')+displayZone"></span>
<button class="text" @click="open('workspace')"><span x-text="t('new_workspace_b21b86')"></span></button>
</div>
</header>
<div class="content">
<template x-if="!workspace">
<section class="empty welcome">
<span class="empty-symbol"><span x-text="t('symbol_4d82f1')"></span></span>
<h1><span x-text="t('your_team_s_next_chapter_starts_here_4e986d')"></span></h1>
<p><span x-text="t('create_a_workspace_add_a_team_and_give_your_on_call__af9412')"></span></p>
<button class="primary" @click="open('workspace')" x-text="t('create_a_workspace_e94c64')">
</button>
<template x-if="view==='instance' && user?.instance_admin">
<section class="instance-page" id="instance">
<div class="page-heading"><div><div class="eyebrow" x-text="t('access_instance_eyebrow')"></div><h1 x-text="t('access_instance_title')"></h1></div></div>
<div class="warning-banner" role="alert" x-show="admin?.config.bootstrap_usernames.length"><span x-text="t('access_bootstrap_warning')"></span></div>
<template x-if="admin">
<div>
<article class="panel rule-card spaced" id="instance-config">
<span class="eyebrow"><span x-text="t('access_config_title')"></span></span>
<dl class="config-summary">
<div><dt x-text="t('access_config_password_login')"></dt><dd x-text="admin.config.password_login?t('access_enabled'):t('access_disabled')"></dd></div>
<div><dt x-text="t('access_config_oidc')"></dt><dd x-text="admin.config.oidc_issuer || t('access_not_configured')"></dd></div>
<div><dt x-text="t('access_config_admin_groups')"></dt><dd x-text="admin.config.instance_admin_groups.join(', ') || t('access_none')"></dd></div>
<div><dt x-text="t('access_config_bootstrap')"></dt><dd x-text="admin.config.bootstrap_usernames.join(', ') || t('access_none')"></dd></div>
</dl>
<p class="muted"><span x-text="t('access_config_note')"></span></p>
</article>
<div class="section-line"><h2><span x-text="t('access_workspaces_title')"></span></h2></div>
<div class="panel compact" id="instance-workspaces">
<p class="muted padded" x-show="!admin.workspaces.length"><span x-text="t('access_workspaces_empty')"></span></p>
<template x-for="w in admin.workspaces" :key="w.id">
<div class="list-row admin-workspace" :class="{disabled:w.disabled_at}" :data-workspace-key="w.key">
<div class="grow">
<strong x-text="w.name"></strong>
<small x-text="t('access_workspace_key',{key:w.key || '—'})+' · '+t('access_workspace_members',{count:w.member_count})+(w.sync_state?' · '+t('access_workspace_publication',{state:w.sync_state}):'')"></small>
<small x-show="w.in_config"><span x-text="t('access_groups_admin')+': '+(w.admin_groups.join(', ') || t('access_none'))"></span></small>
<small x-show="w.in_config"><span x-text="t('access_groups_member')+': '+(w.member_groups.join(', ') || t('access_none'))"></span></small>
<small class="disabled-note" x-show="w.disabled_at" x-text="t('access_workspace_disabled')+' · '+t('access_workspace_disabled_since',{date:formatTime(w.disabled_at)})"></small>
</div>
<span class="badge" x-show="w.in_config" x-text="t('access_workspace_in_config')"></span>
<button class="danger-button" x-show="w.disabled_at" @click="open('admin-delete',{id:w.id,name:w.name,expected:w.confirm,confirm:''})"><span x-text="t('access_delete_workspace')"></span></button>
</div>
</template>
</div>
<div class="section-line"><h2><span x-text="t('access_users_title')"></span></h2>
<button class="primary" @click="open('admin-user')"><span x-text="t('access_add_user')"></span></button>
</div>
<div class="panel compact" id="instance-users">
<p class="muted padded" x-show="!admin.users.length"><span x-text="t('access_users_empty')"></span></p>
<template x-for="u in admin.users" :key="u.id">
<div class="list-row admin-user" :data-username="u.username">
<span class="avatar" x-text="u.display_name.slice(0,1)"></span>
<div class="grow">
<strong x-text="u.display_name"></strong> <small class="inline" x-show="u.id===user.id"><span x-text="t('you_8af56d')"></span></small>
<small x-text="'@'+u.username+(u.email?' · '+u.email:'')"></small>
<small x-text="t('access_user_groups')+': '+(u.groups.join(', ') || t('access_user_no_groups'))"></small>
<small class="muted" x-show="u.sso"><span x-text="t('access_groups_sso_readonly')"></span></small>
<small class="muted" x-show="u.bootstrap"><span x-text="t('access_groups_bootstrap_readonly')"></span></small>
</div>
<span class="badge" x-show="u.instance_admin" x-text="t('access_role_instance_admin')"></span>
<span class="badge" x-show="u.sso" x-text="t('access_badge_sso')"></span>
<span class="badge" x-show="u.bootstrap" x-text="t('access_badge_bootstrap')"></span>
<span class="badge" x-show="!u.sso && !u.bootstrap" x-text="t('access_badge_password')"></span>
<button x-show="canEditUserGroups(u)" @click="open('admin-groups',{id:u.id,username:u.username,groups:[...u.groups]})"><span x-text="t('access_edit_groups')"></span></button>
<button x-show="!u.sso && !u.bootstrap" @click="open('admin-password',{id:u.id,username:u.username,password:''})"><span x-text="t('access_reset_password')"></span></button>
</div>
</template>
</div>
</div>
</template>
</section>
</template>
<template x-if="workspace">
<template x-if="view!=='instance' && !workspace">
<section class="empty welcome">
<span class="empty-symbol"><span x-text="t('symbol_4d82f1')"></span></span>
<h1><span x-text="t('access_no_workspace_title')"></span></h1>
<p x-text="user?.instance_admin?t('access_no_workspace_admin_text'):t('access_no_workspace_text')"></p>
<a class="button primary" x-show="user?.instance_admin" :href="menuHref('instance')" @click="menuClick($event,'instance')"><span x-text="t('access_open_instance_page')"></span></a>
</section>
</template>
<template x-if="view!=='instance' && workspace">
<div>
<div class="page-heading">
<div>
@@ -340,23 +408,43 @@
</div>
<div class="section-line">
<h2><span x-text="t('team_access_8fb357')"></span></h2>
<button class="primary" x-show="can('manage_members')" @click="open('member')"><span x-text="t('add_teammate_d6bc5a')"></span></button>
</div>
<div class="panel compact">
<p class="info-note" x-show="detail?.instance_admin && !members.some(m=>m.user.id===user?.id)"><span x-text="t('access_instance_admin_note')"></span></p>
<div class="panel compact" id="workspace-members">
<template x-for="m in members" :key="m.user?.id">
<div class="list-row">
<div class="list-row" :data-username="m.user.username">
<span class="avatar" :class="color(m.user?.id)" x-text="initials(m.user?.id)">
</span>
<div class="grow">
<strong x-text="m.user.display_name">
</strong>
<small x-text="'@'+m.user.username+' · '+m.permissions.length+t('permissions_607d39')">
<small x-text="'@'+m.user.username">
</small>
</div>
<button x-show="can('manage_members')" @click="open('member',{username:m.user.username,permissions:m.permissions})"><span x-text="t('edit_access_e381a8')"></span></button>
<span class="badge role-badge" :class="{admin:m.is_owner}" x-text="m.is_owner?t('access_role_workspace_admin'):t('access_role_employee')"></span>
</div>
</template>
</div>
<p class="muted"><span x-text="t('access_members_hint')"></span></p>
<article class="panel rule-card spaced" id="employee-permissions">
<span class="eyebrow"><span x-text="t('access_employee_permissions_title')"></span></span>
<p class="muted"><span x-text="t('access_employee_permissions_hint')"></span></p>
<ul class="permission-list">
<template x-for="grant in employeeGrantOptions" :key="grant.id">
<li :class="{on:(doc.member_permissions||[]).includes(grant.id)}" :data-permission="grant.id"><span aria-hidden="true" x-text="(doc.member_permissions||[]).includes(grant.id)?'✓':'·'"></span> <span x-text="grant.name"></span></li>
</template>
</ul>
<p class="info-note" x-text="(doc.member_permissions||[]).includes('force_overrides')?t('access_force_enabled'):t('access_force_disabled')"></p>
<div class="actions" x-show="can('manage_members')"><button @click="open('member-permissions')"><span x-text="t('access_edit_employee_permissions')"></span></button></div>
</article>
<article class="panel rule-card spaced" id="access-groups" x-show="detail?.access">
<span class="eyebrow"><span x-text="t('access_groups_title')"></span></span>
<dl class="config-summary">
<div><dt x-text="t('access_groups_admin')"></dt><dd x-text="(detail?.access?.admin_groups||[]).join(', ') || t('access_none')"></dd></div>
<div><dt x-text="t('access_groups_member')"></dt><dd x-text="(detail?.access?.member_groups||[]).join(', ') || t('access_none')"></dd></div>
</dl>
<p class="muted"><span x-text="t('access_groups_hint')"></span></p>
</article>
<div class="section-line">
<h2><span x-text="t('scheduling_directory_e8fdd4')"></span></h2>
<span class="muted"><span x-text="t('country_controls_public_holidays_defaults_to_cyprus_e828a2')"></span></span>
@@ -407,8 +495,8 @@
<form @submit.prevent="save()">
<div class="modal-heading">
<div>
<span class="eyebrow" x-show="modal!=='workspace'" x-text="['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span>
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),member:t('team_access_8fb357'),workspace:t('new_workspace_b21b86'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
<span class="eyebrow" x-text="modal.startsWith('admin-')?t('access_instance_eyebrow'):['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span>
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),'member-permissions':t('access_employee_permissions_title'),'admin-user':t('access_new_user_title'),'admin-groups':t('access_edit_groups_title'),'admin-password':t('access_reset_password_title'),'admin-delete':t('access_delete_workspace_title'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
</h2>
</div>
<button type="button" class="icon-button" @click="modal=''" :aria-label="t('close_dialog_7b2902')"><span x-text="t('symbol_67fba2')"></span></button>
@@ -471,13 +559,6 @@
</div>
</div>
</template><div x-show="!picker">
<template x-if="modal==='workspace'">
<div>
<label><span x-text="t('name_709a23')"></span><input x-model="form.name" required maxlength="100">
</label>
</div>
</template>
<template x-if="modal==='account'">
<div>
<label><span x-text="t('language')"></span><select x-model="form.language" :aria-label="t('language')"><option value="" x-text="t('language_auto')"></option><template x-for="code in I18n.supported"><option :value="code" :selected="form.language===code" x-text="t('language_'+code)"></option></template></select></label>
@@ -496,10 +577,13 @@
<p class="info-note" x-text="t('dates_and_timestamps_are_displayed_in_77ec74')+(form.timezone || browserZone)+t('this_never_changes_the_workspace_timezone_or_shifts_1de864')">
</p>
<p class="muted"><span x-text="t('country_changes_update_your_holiday_calendar_in_team_1e4eb8')"></span></p>
<template x-if="user?.has_password"><div>
<label><span x-text="t('current_password_19dff4')"></span><input type="password" autocomplete="current-password" x-model="form.current_password">
</label>
<label><span x-text="t('new_password_d850ee')"></span><input type="password" autocomplete="new-password" minlength="12" x-model="form.password">
</label>
</div></template>
<p class="muted" x-show="!user?.has_password"><span x-text="t('access_sso_password_note')"></span></p>
</div>
</template>
<template x-if="modal==='person'">
@@ -562,7 +646,7 @@
<div class="grow">
<strong x-text="person(id)">
</strong>
<small x-text="countryName(people.find(p=>p.id===id)?.country)">
<small x-text="countryName(personById(id)?.country)">
</small>
</div>
<button type="button" @click="editPerson(id)" :aria-label="t('edit_participant_details_73e491')"><span x-text="t('details_dc3dec')"></span></button>
@@ -735,18 +819,62 @@
</div>
</div>
</template>
<template x-if="modal==='member'">
<template x-if="modal==='member-permissions'">
<div>
<label><span x-text="t('registered_username_e29aa9')"></span><input x-model="form.username" required>
</label>
<p class="muted"><span x-text="t('your_teammate_needs_an_account_before_you_can_add_th_80fa32')"></span></p>
<template x-for="grant in grantOptions">
<p class="muted"><span x-text="t('access_employee_permissions_hint')"></span></p>
<template x-for="grant in employeeGrantOptions" :key="grant.id">
<label class="check">
<input type="checkbox" :value="grant.id" x-model="form.permissions" :disabled="grant.id==='view'">
<span x-text="grant.name">
</span>
<input type="checkbox" :value="grant.id" x-model="form.permissions" :disabled="grant.id==='view'" :data-permission="grant.id">
<span x-text="grant.name"></span>
</label>
</template>
<p class="info-note" x-text="t('access_force_hint')"></p>
</div>
</template>
<template x-if="modal==='admin-user'">
<div>
<label><span x-text="t('username_84c290')"></span><input x-model="form.username" autocomplete="off" required minlength="3" maxlength="64">
</label>
<label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required>
</label>
<label><span x-text="t('access_email_optional')"></span><input type="email" x-model="form.email" autocomplete="off">
</label>
<label><span x-text="t('password_8be3c9')"></span><input type="password" x-model="form.password" autocomplete="new-password" required minlength="12" maxlength="256">
</label>
<p class="muted"><span x-text="t('access_password_hint')"></span></p>
<fieldset class="group-picker"><legend x-text="t('access_user_groups')"></legend>
<p class="muted" x-show="!admin?.known_groups.length"><span x-text="t('access_no_known_groups')"></span></p>
<template x-for="group in admin?.known_groups || []" :key="group">
<label class="check"><input type="checkbox" :value="group" x-model="form.groups"><span x-text="group"></span></label>
</template>
</fieldset>
</div>
</template>
<template x-if="modal==='admin-groups'">
<div>
<p class="muted"><span x-text="t('access_groups_picker_hint')"></span></p>
<fieldset class="group-picker"><legend x-text="'@'+form.username"></legend>
<p class="muted" x-show="!admin?.known_groups.length"><span x-text="t('access_no_known_groups')"></span></p>
<template x-for="group in admin?.known_groups || []" :key="group">
<label class="check"><input type="checkbox" :value="group" x-model="form.groups"><span x-text="group"></span></label>
</template>
</fieldset>
</div>
</template>
<template x-if="modal==='admin-password'">
<div>
<p class="muted" x-text="'@'+form.username"></p>
<label><span x-text="t('new_password_d850ee')"></span><input type="password" x-model="form.password" autocomplete="new-password" required minlength="12" maxlength="256">
</label>
<p class="muted"><span x-text="t('access_password_hint')"></span></p>
</div>
</template>
<template x-if="modal==='admin-delete'">
<div>
<p class="info-note danger-note"><span x-text="t('access_delete_warning')"></span></p>
<p><strong x-text="form.name"></strong></p>
<label><span x-text="t('access_delete_confirm_label',{confirm:form.expected})"></span><input x-model="form.confirm" autocomplete="off" required>
</label>
</div>
</template>
<template x-if="modal==='preferences'">
@@ -918,7 +1046,7 @@
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
<button type="button" class="primary" x-show="rulePeopleTarget" @click="closeRuleEditor()" x-text="t('done_e9b450')"></button>
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule)" x-text="busy?t('saving_56a228'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?t('send_for_confirmation_f3df0f'):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule) || (modal==='admin-delete' && form.confirm!==form.expected)" x-text="busy?t('saving_56a228'):modal==='admin-user'?t('access_create_user'):modal==='admin-password'?t('access_reset_password'):modal==='admin-delete'?t('access_delete_permanently'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?t('send_for_confirmation_f3df0f'):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
</button>
</div>
</div>
+468 -118
View File
@@ -25,9 +25,9 @@
"fr": " · demandes : "
},
"visitor_accounts_f4ac15": {
"en": " · visitor accounts: ",
"ru": " · аккаунты посетителей: ",
"fr": " · comptes visiteurs : "
"en": " · added accounts: ",
"ru": " · добавленные аккаунты: ",
"fr": " · comptes ajoutés : "
},
"restart_resets_demo_data_examples_refresh_automatica_0082d1": {
"en": "Restart resets demo data. Examples refresh automatically.",
@@ -99,11 +99,6 @@
"ru": "ДЕЖУРСТВА ВАШЕЙ КОМАНДЫ",
"fr": "LES ASTREINTES DE VOTRE ÉQUIPE"
},
"create_your_account_4046b5": {
"en": "Create your account",
"ru": "Создайте аккаунт",
"fr": "Créez votre compte"
},
"welcome_back_b80783": {
"en": "Welcome back",
"ru": "С возвращением",
@@ -129,71 +124,16 @@
"ru": "Пароль",
"fr": "Mot de passe"
},
"create_account_aaf374": {
"en": "Create account",
"ru": "Создать аккаунт",
"fr": "Créer un compte"
},
"sign_in_ada2e9": {
"en": "Sign in",
"ru": "Войти",
"fr": "Se connecter"
},
"already_registered_sign_in_0df05c": {
"en": "Already registered? Sign in",
"ru": "Уже зарегистрированы? Войти",
"fr": "Déjà inscrit ? Se connecter"
},
"new_here_create_an_account_2b2c3f": {
"en": "New here? Create an account",
"ru": "Впервые здесь? Создать аккаунт",
"fr": "Nouveau ? Créer un compte"
},
"try_the_demo_38cfaf": {
"en": "Try the demo",
"ru": "Попробуйте демо",
"fr": "Essayez la démonstration"
},
"alice_522b27": {
"en": "alice",
"ru": "alice",
"fr": "alice"
},
"lead_both_workspaces_variables_rules_ed9a19": {
"en": "Lead · all four workspaces, variables & rules",
"ru": "Тимлид · все четыре пространства, переменные и правила",
"fr": "Responsable · les quatre espaces, variables et règles"
},
"bob_48181a": {
"en": "bob",
"ru": "bob",
"fr": "bob"
},
"engineering_weekly_on_call_swaps_de49e8": {
"en": "Engineering & regions · rotations and swaps",
"ru": "Разработка и регионы · ротации и подмены",
"fr": "Ingénierie et régions · rotations et échanges"
},
"carol_28b92b": {
"en": "carol",
"ru": "carol",
"fr": "carol"
},
"support_daily_shifts_approvals_c9a0d8": {
"en": "Support & commerce · shifts and service tiers",
"ru": "Поддержка и коммерция · смены и уровни обслуживания",
"fr": "Assistance et commerce · rotations et niveaux de service"
},
"password_for_all_2f1a67": {
"en": "Password for all:",
"ru": "Общий пароль:",
"fr": "Mot de passe commun :"
},
"meguri_demo_only_0c7b5d": {
"en": "meguri-demo-only",
"ru": "meguri-demo-only",
"fr": "meguri-demo-only"
},
"workspace_703988": {
"en": "WORKSPACE",
"ru": "ПРОСТРАНСТВО",
@@ -254,26 +194,6 @@
"ru": "Ваш часовой пояс: ",
"fr": "Votre fuseau : "
},
"new_workspace_b21b86": {
"en": "New workspace",
"ru": "Новое пространство",
"fr": "Nouvel espace"
},
"your_team_s_next_chapter_starts_here_4e986d": {
"en": "Your team’s next chapter starts here.",
"ru": "Начните планировать дежурства команды.",
"fr": "Organisez les prochaines astreintes de votre équipe."
},
"create_a_workspace_add_a_team_and_give_your_on_call__af9412": {
"en": "Create a workspace, invite colleagues and set up your schedules.",
"ru": "Создайте пространство, пригласите коллег и настройте расписания.",
"fr": "Créez un espace, invitez vos collègues et configurez les plannings."
},
"create_a_workspace_e94c64": {
"en": "Create a workspace",
"ru": "Создать пространство",
"fr": "Créer un espace"
},
"team_setup_504738": {
"en": "WORKSPACE SETUP",
"ru": "НАСТРОЙКИ ПРОСТРАНСТВА",
@@ -934,21 +854,6 @@
"ru": "Доступ к пространству",
"fr": "Accès à l’espace"
},
"add_teammate_d6bc5a": {
"en": "+ Add teammate",
"ru": "+ Добавить коллегу",
"fr": "+ Ajouter un collègue"
},
"permissions_607d39": {
"en": " permissions",
"ru": " разрешений",
"fr": " autorisations"
},
"edit_access_e381a8": {
"en": "Edit access",
"ru": "Изменить доступ",
"fr": "Modifier l’accès"
},
"scheduling_directory_e8fdd4": {
"en": "Scheduling directory",
"ru": "Участники расписаний",
@@ -1889,16 +1794,6 @@
"ru": "Будет добавлено",
"fr": "Sera ajouté"
},
"registered_username_e29aa9": {
"en": "Registered username",
"ru": "Имя зарегистрированного пользователя",
"fr": "Identifiant enregistré"
},
"your_teammate_needs_an_account_before_you_can_add_th_80fa32": {
"en": "Your teammate needs an account before you can add them.",
"ru": "Чтобы добавить коллегу, ему нужен аккаунт.",
"fr": "Votre collègue doit avoir un compte pour être ajouté."
},
"team_name_9b11ed": {
"en": "Workspace name",
"ru": "Название пространства",
@@ -5624,16 +5519,6 @@
"ru": "Без изменений: {count}",
"fr": "Inchangés : {count}"
},
"demo_extra_users": {
"en": "dave / erin",
"ru": "dave / erin",
"fr": "dave / erin"
},
"demo_extra_users_hint": {
"en": "Engineering · service ownership and cover",
"ru": "Engineering · ответственные и подмены",
"fr": "Engineering · responsables et remplacements"
},
"rule_result_title": {
"en": "Generated resources",
"ru": "Ресурсы правила",
@@ -5953,5 +5838,470 @@
"en": "Select a pass above to inspect its values.",
"ru": "Выберите проход выше, чтобы увидеть его значения.",
"fr": "Sélectionnez un passage ci-dessus pour voir ses valeurs."
},
"access_sso_button": {
"en": "Sign in with SSO",
"ru": "Войти через SSO",
"fr": "Se connecter avec SSO"
},
"access_or_divider": {
"en": "or",
"ru": "или",
"fr": "ou"
},
"access_login_error_no_access": {
"en": "The instance could not map your groups to any workspace. Contact the service administrator.",
"ru": "Инстанс не смог сопоставить ваши группы ни с одним пространством. Обратитесь к администратору сервиса.",
"fr": "L’instance n’a pu associer vos groupes à aucun espace. Contactez l’administrateur du service."
},
"access_login_error_failed": {
"en": "Single sign-on did not complete. Try again or contact the service administrator.",
"ru": "Вход через SSO не завершился. Повторите попытку или обратитесь к администратору сервиса.",
"fr": "L’authentification unique n’a pas abouti. Réessayez ou contactez l’administrateur du service."
},
"access_login_error_unavailable": {
"en": "The sign-in provider is unavailable. Try again later or contact the service administrator.",
"ru": "Провайдер входа недоступен. Повторите позже или обратитесь к администратору сервиса.",
"fr": "Le fournisseur d’authentification est indisponible. Réessayez plus tard ou contactez l’administrateur du service."
},
"access_support_reference": {
"en": "Reference for support: {ref}",
"ru": "Код для обращения в поддержку: {ref}",
"fr": "Référence pour le support : {ref}"
},
"access_no_sign_in_method": {
"en": "No sign-in method is configured. Contact the service administrator.",
"ru": "Способ входа не настроен. Обратитесь к администратору сервиса.",
"fr": "Aucune méthode de connexion n’est configurée. Contactez l’administrateur du service."
},
"access_demo_accounts": {
"en": "Demo accounts",
"ru": "Демо-аккаунты",
"fr": "Comptes de démonstration"
},
"access_demo_password": {
"en": "Password for every account this run:",
"ru": "Пароль всех аккаунтов этого запуска:",
"fr": "Mot de passe de tous les comptes de cette exécution :"
},
"access_demo_use_account": {
"en": "Sign in as {username}",
"ru": "Войти как {username}",
"fr": "Se connecter en tant que {username}"
},
"access_demo_copy_password": {
"en": "Copy password",
"ru": "Скопировать пароль",
"fr": "Copier le mot de passe"
},
"access_demo_password_copied": {
"en": "Password copied",
"ru": "Пароль скопирован",
"fr": "Mot de passe copié"
},
"access_demo_restart_hint": {
"en": "The password and all data change on every restart.",
"ru": "Пароль и все данные меняются при каждом перезапуске.",
"fr": "Le mot de passe et toutes les données changent à chaque redémarrage."
},
"access_role_instance_admin": {
"en": "Instance administrator",
"ru": "Администратор инстанса",
"fr": "Administrateur de l’instance"
},
"access_role_workspace_admin": {
"en": "Workspace administrator",
"ru": "Администратор пространства",
"fr": "Administrateur de l’espace"
},
"access_role_employee": {
"en": "Employee",
"ru": "Сотрудник",
"fr": "Employé"
},
"access_no_workspace_title": {
"en": "No workspace access yet",
"ru": "Пока нет доступа к пространствам",
"fr": "Aucun accès à un espace pour l’instant"
},
"access_no_workspace_text": {
"en": "Your account is not linked to any workspace. Contact the service administrator to get access.",
"ru": "Ваша учётная запись не связана ни с одним пространством. Обратитесь к администратору сервиса, чтобы получить доступ.",
"fr": "Votre compte n’est lié à aucun espace. Contactez l’administrateur du service pour obtenir l’accès."
},
"access_no_workspace_admin_text": {
"en": "No workspace is enabled. Workspaces come from the instance configuration; review them on the instance page.",
"ru": "Нет включённых пространств. Пространства задаются в конфигурации инстанса; посмотрите их на странице инстанса.",
"fr": "Aucun espace n’est activé. Les espaces viennent de la configuration de l’instance ; consultez-les sur la page de l’instance."
},
"access_open_instance_page": {
"en": "Open instance administration",
"ru": "Открыть администрирование инстанса",
"fr": "Ouvrir l’administration de l’instance"
},
"access_instance_nav": {
"en": "Instance",
"ru": "Инстанс",
"fr": "Instance"
},
"access_instance_title": {
"en": "Instance administration",
"ru": "Администрирование инстанса",
"fr": "Administration de l’instance"
},
"access_instance_eyebrow": {
"en": "Instance",
"ru": "Инстанс",
"fr": "Instance"
},
"access_bootstrap_warning": {
"en": "Bootstrap access is enabled. These accounts have full administrator access and their password is written to the server log. Remove the bootstrap setting from the configuration when it is no longer needed.",
"ru": "Включён bootstrap-доступ. Эти аккаунты имеют полные права администратора, а их пароль записан в лог сервера. Уберите bootstrap из конфигурации, когда он больше не нужен.",
"fr": "L’accès bootstrap est activé. Ces comptes ont tous les droits d’administration et leur mot de passe est écrit dans le journal du serveur. Retirez le réglage bootstrap de la configuration quand il n’est plus nécessaire."
},
"access_config_title": {
"en": "Configuration (read-only)",
"ru": "Конфигурация (только чтение)",
"fr": "Configuration (lecture seule)"
},
"access_config_note": {
"en": "Workspaces and group mapping are defined only in the instance configuration file. They cannot be edited here.",
"ru": "Пространства и сопоставление групп задаются только в файле конфигурации инстанса. Здесь их изменить нельзя.",
"fr": "Les espaces et l’association des groupes sont définis uniquement dans le fichier de configuration de l’instance. Ils ne peuvent pas être modifiés ici."
},
"access_config_password_login": {
"en": "Password sign-in",
"ru": "Вход по паролю",
"fr": "Connexion par mot de passe"
},
"access_config_oidc": {
"en": "SSO issuer",
"ru": "Провайдер SSO",
"fr": "Fournisseur SSO"
},
"access_config_admin_groups": {
"en": "Instance administrator groups",
"ru": "Группы администраторов инстанса",
"fr": "Groupes d’administrateurs de l’instance"
},
"access_config_bootstrap": {
"en": "Bootstrap accounts",
"ru": "Bootstrap-аккаунты",
"fr": "Comptes bootstrap"
},
"access_enabled": {
"en": "Enabled",
"ru": "Включено",
"fr": "Activé"
},
"access_disabled": {
"en": "Disabled",
"ru": "Выключено",
"fr": "Désactivé"
},
"access_not_configured": {
"en": "Not configured",
"ru": "Не настроено",
"fr": "Non configuré"
},
"access_none": {
"en": "None",
"ru": "Нет",
"fr": "Aucun"
},
"access_workspaces_title": {
"en": "Workspaces",
"ru": "Пространства",
"fr": "Espaces"
},
"access_workspace_key": {
"en": "Key: {key}",
"ru": "Ключ: {key}",
"fr": "Clé : {key}"
},
"access_workspace_members": {
"en": "Members: {count}",
"ru": "Участников: {count}",
"fr": "Membres : {count}"
},
"access_workspace_publication": {
"en": "Publication: {state}",
"ru": "Публикация: {state}",
"fr": "Publication : {state}"
},
"access_workspace_disabled": {
"en": "Disabled — not in configuration",
"ru": "Отключено — нет в конфигурации",
"fr": "Désactivé — absent de la configuration"
},
"access_workspace_disabled_since": {
"en": "Disabled since {date}",
"ru": "Отключено с {date}",
"fr": "Désactivé depuis le {date}"
},
"access_workspace_in_config": {
"en": "In configuration",
"ru": "В конфигурации",
"fr": "Dans la configuration"
},
"access_workspaces_empty": {
"en": "No workspaces exist yet.",
"ru": "Пространств пока нет.",
"fr": "Aucun espace pour l’instant."
},
"access_delete_workspace": {
"en": "Delete from database",
"ru": "Удалить из базы",
"fr": "Supprimer de la base"
},
"access_delete_workspace_title": {
"en": "Delete workspace",
"ru": "Удаление пространства",
"fr": "Supprimer l’espace"
},
"access_delete_warning": {
"en": "This permanently deletes the workspace with all its schedules, history and audit records. Resources already published to an external provider are not removed.",
"ru": "Пространство будет удалено навсегда вместе со всеми расписаниями, историей и записями аудита. Ресурсы, уже опубликованные во внешнем провайдере, не удаляются.",
"fr": "Cette action supprime définitivement l’espace avec tous ses plannings, son historique et son audit. Les ressources déjà publiées chez un fournisseur externe ne sont pas supprimées."
},
"access_delete_confirm_label": {
"en": "Type {confirm} to confirm",
"ru": "Введите {confirm} для подтверждения",
"fr": "Saisissez {confirm} pour confirmer"
},
"access_delete_permanently": {
"en": "Delete permanently",
"ru": "Удалить навсегда",
"fr": "Supprimer définitivement"
},
"access_workspace_deleted": {
"en": "Workspace deleted",
"ru": "Пространство удалено",
"fr": "Espace supprimé"
},
"access_users_title": {
"en": "Users",
"ru": "Пользователи",
"fr": "Utilisateurs"
},
"access_users_empty": {
"en": "No users yet.",
"ru": "Пользователей пока нет.",
"fr": "Aucun utilisateur pour l’instant."
},
"access_add_user": {
"en": "Add user",
"ru": "Добавить пользователя",
"fr": "Ajouter un utilisateur"
},
"access_new_user_title": {
"en": "New user",
"ru": "Новый пользователь",
"fr": "Nouvel utilisateur"
},
"access_create_user": {
"en": "Create user",
"ru": "Создать пользователя",
"fr": "Créer l’utilisateur"
},
"access_user_created": {
"en": "User created",
"ru": "Пользователь создан",
"fr": "Utilisateur créé"
},
"access_email_optional": {
"en": "Email (optional)",
"ru": "Email (необязательно)",
"fr": "E-mail (facultatif)"
},
"access_password_hint": {
"en": "At least 12 characters. Share it with the user through a secure channel.",
"ru": "Не менее 12 символов. Передайте его пользователю по защищённому каналу.",
"fr": "Au moins 12 caractères. Transmettez-le à la personne par un canal sûr."
},
"access_badge_sso": {
"en": "SSO",
"ru": "SSO",
"fr": "SSO"
},
"access_badge_password": {
"en": "Password",
"ru": "Пароль",
"fr": "Mot de passe"
},
"access_badge_bootstrap": {
"en": "Bootstrap",
"ru": "Bootstrap",
"fr": "Bootstrap"
},
"access_user_groups": {
"en": "Groups",
"ru": "Группы",
"fr": "Groupes"
},
"access_user_no_groups": {
"en": "No groups",
"ru": "Нет групп",
"fr": "Aucun groupe"
},
"access_groups_sso_readonly": {
"en": "Managed by the identity provider; shown here for reference.",
"ru": "Управляется провайдером входа; показано для справки.",
"fr": "Géré par le fournisseur d’identité ; affiché à titre indicatif."
},
"access_groups_bootstrap_readonly": {
"en": "Managed by the instance configuration.",
"ru": "Управляется конфигурацией инстанса.",
"fr": "Géré par la configuration de l’instance."
},
"access_groups_self_readonly": {
"en": "You cannot change your own groups.",
"ru": "Нельзя менять собственные группы.",
"fr": "Vous ne pouvez pas modifier vos propres groupes."
},
"access_edit_groups": {
"en": "Edit groups",
"ru": "Изменить группы",
"fr": "Modifier les groupes"
},
"access_edit_groups_title": {
"en": "User groups",
"ru": "Группы пользователя",
"fr": "Groupes de l’utilisateur"
},
"access_groups_picker_hint": {
"en": "Choose the groups this user belongs to. Groups come from the instance configuration.",
"ru": "Выберите группы пользователя. Список групп берётся из конфигурации инстанса.",
"fr": "Choisissez les groupes de cette personne. Les groupes viennent de la configuration de l’instance."
},
"access_no_known_groups": {
"en": "The configuration defines no groups.",
"ru": "В конфигурации не задано ни одной группы.",
"fr": "La configuration ne définit aucun groupe."
},
"access_groups_saved": {
"en": "Groups saved",
"ru": "Группы сохранены",
"fr": "Groupes enregistrés"
},
"access_reset_password": {
"en": "Reset password",
"ru": "Сбросить пароль",
"fr": "Réinitialiser le mot de passe"
},
"access_reset_password_title": {
"en": "Reset password",
"ru": "Сброс пароля",
"fr": "Réinitialisation du mot de passe"
},
"access_password_reset_done": {
"en": "Password changed",
"ru": "Пароль изменён",
"fr": "Mot de passe modifié"
},
"access_groups_title": {
"en": "Groups that grant access",
"ru": "Группы, дающие доступ",
"fr": "Groupes donnant accès"
},
"access_groups_admin": {
"en": "Workspace administrators",
"ru": "Администраторы пространства",
"fr": "Administrateurs de l’espace"
},
"access_groups_member": {
"en": "Employees",
"ru": "Сотрудники",
"fr": "Employés"
},
"access_groups_hint": {
"en": "Access follows the user’s groups at sign-in. These groups come from the instance configuration and cannot be changed here.",
"ru": "Доступ определяется группами пользователя при входе. Эти группы заданы в конфигурации инстанса и здесь не меняются.",
"fr": "L’accès suit les groupes de la personne à la connexion. Ces groupes viennent de la configuration de l’instance et ne peuvent pas être modifiés ici."
},
"access_members_hint": {
"en": "Members are added automatically when they sign in with a matching group.",
"ru": "Участники добавляются автоматически при входе с подходящей группой.",
"fr": "Les membres sont ajoutés automatiquement à la connexion avec un groupe correspondant."
},
"access_employee_permissions_title": {
"en": "Employee permissions",
"ru": "Права сотрудников",
"fr": "Autorisations des employés"
},
"access_employee_permissions_hint": {
"en": "Applies to every employee of this workspace. Workspace administrators always have every permission.",
"ru": "Действует для всех сотрудников этого пространства. Администраторы пространства всегда имеют все права.",
"fr": "S’applique à tous les employés de cet espace. Les administrateurs de l’espace ont toujours toutes les autorisations."
},
"access_edit_employee_permissions": {
"en": "Edit employee permissions",
"ru": "Изменить права сотрудников",
"fr": "Modifier les autorisations des employés"
},
"access_force_hint": {
"en": "With this permission an employee can apply a replacement immediately, without waiting for confirmation from the other person.",
"ru": "С этим правом сотрудник может применить подмену сразу, не дожидаясь согласия второго человека.",
"fr": "Avec cette autorisation, un employé peut appliquer un remplacement immédiatement, sans attendre la validation de l’autre personne."
},
"access_force_enabled": {
"en": "Replacements can be applied without confirmation",
"ru": "Подмены можно применять без согласования",
"fr": "Les remplacements peuvent être appliqués sans validation"
},
"access_force_disabled": {
"en": "Replacements need confirmation from the other person",
"ru": "Подмены требуют согласия второго человека",
"fr": "Les remplacements nécessitent la validation de l’autre personne"
},
"access_sso_password_note": {
"en": "Your password is managed by your identity provider.",
"ru": "Пароль управляется вашим провайдером входа.",
"fr": "Votre mot de passe est géré par votre fournisseur d’identité."
},
"access_instance_admin_note": {
"en": "You see this workspace as an instance administrator.",
"ru": "Вы видите это пространство как администратор инстанса.",
"fr": "Vous voyez cet espace en tant qu’administrateur de l’instance."
},
"err_employees_cannot_manage_members_or_integrations_3c21aa": {
"en": "Employees cannot be allowed to manage members or integrations",
"ru": "Сотрудникам нельзя разрешить управлять участниками или интеграциями",
"fr": "Les employés ne peuvent pas gérer les membres ni les intégrations"
},
"err_choose_groups_from_the_configured_list_5d0e71": {
"en": "Choose groups from the configured list",
"ru": "Выберите группы из настроенного списка",
"fr": "Choisissez des groupes dans la liste configurée"
},
"err_you_cannot_change_your_own_access_9b44c0": {
"en": "You cannot change your own access",
"ru": "Нельзя изменять собственный доступ",
"fr": "Vous ne pouvez pas modifier votre propre accès"
},
"err_this_account_is_managed_by_sso_or_configuration_a3c8e2": {
"en": "This account is managed by SSO or by the instance configuration",
"ru": "Эта учётная запись управляется через SSO или конфигурацию инстанса",
"fr": "Ce compte est géré par le SSO ou par la configuration de l’instance"
},
"err_only_disabled_workspaces_can_be_deleted_8f27d1": {
"en": "Only disabled workspaces that are absent from the configuration can be deleted",
"ru": "Удалять можно только отключённые рабочие пространства, которых нет в конфигурации",
"fr": "Seuls les espaces de travail désactivés et absents de la configuration peuvent être supprimés"
},
"err_type_the_workspace_key_to_confirm_deletion_4be9a0": {
"en": "Type the workspace key to confirm deletion",
"ru": "Введите ключ рабочего пространства для подтверждения удаления",
"fr": "Saisissez la clé de l’espace de travail pour confirmer la suppression"
},
"err_the_workspace_is_publishing_try_again_shortly_2a7d94": {
"en": "The workspace is publishing right now; try again shortly",
"ru": "Рабочее пространство сейчас публикуется; повторите чуть позже",
"fr": "L’espace de travail est en cours de publication ; réessayez dans un instant"
},
"err_this_account_signs_in_with_sso_and_has_no_password_6d1f3b": {
"en": "This account signs in with SSO and has no password",
"ru": "Эта учётная запись входит через SSO и не имеет пароля",
"fr": "Ce compte se connecte par SSO et n’a pas de mot de passe"
}
}
+1267
View File
File diff suppressed because it is too large. Load diff
+6 -7
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
@@ -11,14 +11,13 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
const fmt=iso=>iso.split('-').reverse().join('-');
try {
const unique=Date.now();await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
// Sandbox 4 belongs to this suite: an administrator and two substitutes (Bob Chen, Carol Substitute).
const tester=await isolatedUser({sandbox:4,prefix:'absence',display:'Absence tester',employees:['Bob Chen','Carol Substitute']});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
const dates=await page.evaluate(async()=>{
const app=Alpine.$data(document.body);
for(const username of ['bob','carol']){await app.api(app.endpoint('/members'),'PUT',{revision:app.workspace.revision,username,permissions:['view','request_overrides']});await app.loadWorkspace();}
const bob=app.directory.find(p=>p.username==='bob').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
const bob=app.directory.find(p=>p.display_name==='Bob Chen').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
const rotation=crypto.randomUUID();
doc.schedules=[{id:rotation,name:'General',handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:start,shift_days:7},{id:crypto.randomUUID(),name:'Backup',handover:'09:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}];
for(let i=1;i<=9;i++)doc.schedules.push({id:crypto.randomUUID(),name:'Project '+i,handover:'11:00:00',type:'coverage',primary:app.user.id,secondary:bob,fallback:rotation});
+176
View File
@@ -0,0 +1,176 @@
const { openSetup, signIn, apiLogin, adminApi, demoPassword, launchBrowser, baseUrl } = require('./support/browser.cjs');
// Roles and access: demo login guide, instance administration, workspace-role permissions, isolation.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = baseUrl();
assert(['127.0.0.1', 'localhost'].includes(new URL(base).hostname), 'Browser tests require a disposable local instance');
fs.mkdirSync('test-results', { recursive: true });
(async () => {
const browser = await launchBrowser();
const errors = [];
const pages = [];
const open = async (viewport = { width: 1440, height: 1100 }) => {
const context = await browser.newContext({ locale: 'en-US', viewport });
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', e => errors.push(e.message));
pages.push(page);
return page;
};
const dialog = page => page.getByRole('dialog');
const save = async (page, name = 'Save changes') => {
await dialog(page).getByRole('button', { name, exact: true }).click();
await dialog(page).waitFor({ state: 'hidden' });
};
const selectWorkspace = async (page, name) => {
const id = await page.evaluate(name => Alpine.$data(document.body).directoryState.workspaces.find(w => w.name === name)?.id, name);
assert(id, 'workspace visible: ' + name);
await page.getByLabel('Workspace', { exact: true }).selectOption(id);
await page.waitForFunction(id => Alpine.$data(document.body).workspace?.id === id, id);
return id;
};
try {
const secret = await demoPassword();
const admin = await adminApi();
const overview = await admin.get('/api/admin/overview');
const commerce = overview.workspaces.find(w => w.name === 'Commerce Operations');
assert(commerce && commerce.admin_groups.length && commerce.member_groups.length, 'demo configuration defines groups per workspace');
// 1. Login screen: demo accounts with roles and the generated password; no registration; SSO only when configured.
const guest = await open();
await guest.goto(base);
await guest.locator('.demo-note').waitFor();
for (const [name, role] of [['admin', 'Instance administrator'], ['alice', 'Workspace administrator'], ['bob', 'Employee']])
assert.match(await guest.locator(`[data-demo-account=${name}]`).innerText(), new RegExp(role));
assert.equal((await guest.locator('[data-demo-password]').innerText()).trim(), secret);
assert.equal(await guest.getByRole('button', { name: /create (an )?account/i }).count(), 0, 'No self-registration');
const session = await guest.evaluate(() => fetch('/api/session').then(r => r.json()));
assert.equal(await guest.getByRole('link', { name: 'Sign in with SSO' }).isVisible(), !!session.auth.oidc);
const registration = await guest.request.post(base + '/api/register', { headers: { Origin: base }, data: { username: 'intruder1', password: 'long-enough-password', display_name: 'X' } });
assert([404, 405].includes(registration.status()), 'Registration endpoint is gone');
await guest.locator('[data-demo-account=alice]').click();
assert.equal(await guest.getByLabel('Username', { exact: true }).inputValue(), 'alice');
assert.equal(await guest.getByLabel('Password', { exact: true }).inputValue(), secret);
await guest.screenshot({ path: 'test-results/access-login.png', fullPage: true });
// 2. A failed single sign-on shows a localized message and a support reference, then cleans the URL.
const failed = await open();
await failed.goto(base + '/?login_error=no_access&ref=abc123');
await failed.locator('.login-error').waitFor();
assert.match(await failed.locator('.login-error').innerText(), /could not map your groups to any workspace\. Contact the service administrator\./);
assert.match(await failed.locator('.login-error').innerText(), /abc123/);
assert.equal(new URL(failed.url()).searchParams.has('login_error') || new URL(failed.url()).searchParams.has('ref'), false);
await failed.screenshot({ path: 'test-results/access-login-error.png', fullPage: true });
// 3. Instance administration.
const root = await open();
await signIn(root, 'admin');
await root.getByRole('link', { name: 'Instance', exact: true }).click();
await root.getByRole('heading', { name: 'Instance administration', exact: true }).waitFor();
assert.equal(new URL(root.url()).searchParams.get('view'), 'instance');
for (const key of overview.workspaces.map(w => w.key)) await root.locator(`#instance-workspaces [data-workspace-key="${key}"]`).waitFor();
assert.equal(await root.locator('#instance-workspaces .admin-workspace').count(), overview.workspaces.length);
assert.equal(await root.locator('#instance-workspaces').getByRole('button').count(), 0, 'Workspaces are read-only; only disabled ones can be deleted');
await root.locator('.warning-banner').waitFor();
assert.match(await root.locator('.warning-banner').innerText(), /Bootstrap access is enabled/);
assert.match(await root.locator('#instance-config').innerText(), /Password sign-in[\s\S]*Enabled/);
const adminRow = root.locator('#instance-users [data-username=admin]');
assert.match(await adminRow.innerText(), /Bootstrap/);
assert.equal(await adminRow.getByRole('button', { name: 'Edit groups' }).count(), 0, 'Bootstrap accounts are read-only');
assert.equal(await root.locator('#instance-users [data-username=alice]').getByRole('button', { name: 'Edit groups' }).count(), 1);
for (const u of overview.users.filter(u => u.sso)) assert.equal(await root.locator(`#instance-users [data-username=${u.username}]`).getByRole('button', { name: 'Edit groups' }).count(), 0, 'SSO groups are read-only');
await root.screenshot({ path: 'test-results/access-instance.png', fullPage: true });
const username = 'access-' + Date.now().toString(36);
await root.getByRole('button', { name: 'Add user', exact: true }).click();
await dialog(root).getByLabel('Username', { exact: true }).fill(username);
await dialog(root).getByLabel('Display name', { exact: true }).fill('Access tester');
await dialog(root).getByLabel('Password', { exact: true }).fill('first-test-password-1');
await dialog(root).getByLabel(commerce.member_groups[0], { exact: true }).check();
await save(root, 'Create user');
const row = root.locator(`#instance-users [data-username=${username}]`);
await row.waitFor();
assert.match(await row.innerText(), new RegExp(commerce.member_groups[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
await row.getByRole('button', { name: 'Edit groups' }).click();
await dialog(root).getByLabel(commerce.member_groups[0], { exact: true }).uncheck();
await save(root);
await row.getByText('No groups').waitFor();
// Without a matching group the account has no workspace access: it sees the empty state and no administration.
await row.getByRole('button', { name: 'Reset password' }).click();
await dialog(root).getByLabel('New password', { exact: true }).fill('second-test-password-2');
await save(root, 'Reset password');
const outsider = await open();
await signIn(outsider, username, 'second-test-password-2');
await outsider.getByRole('heading', { name: 'No workspace access yet', exact: true }).waitFor();
assert.equal(await outsider.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false);
assert.equal((await (await apiLogin(username, 'second-test-password-2')).call('GET', '/api/admin/overview')).status, 403);
// Your own groups cannot be edited from the page.
assert.equal(await root.locator('#instance-users [data-username=admin]').getByRole('button', { name: 'Edit groups' }).count(), 0);
// 4. Workspace administrators edit the employee role; employees see it read-only.
const lead = await open();
await signIn(lead, 'alice');
assert.equal(await lead.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false, 'Workspace administrators do not see instance administration');
assert.equal((await (await apiLogin('alice')).call('GET', '/api/admin/overview')).status, 403);
const commerceId = await selectWorkspace(lead, 'Commerce Operations');
await openSetup(lead, 'Workspace settings');
await lead.locator('#employee-permissions').waitFor();
assert.equal(await lead.locator('#employee-permissions [data-permission=force_overrides].on').count(), 0, 'Force is off by default');
assert.match(await lead.locator('#access-groups').innerText(), new RegExp(commerce.admin_groups[0].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
assert.equal(await lead.locator('#workspace-members [data-username=carol] .role-badge').innerText(), 'Employee');
assert.equal(await lead.getByRole('button', { name: /add teammate/i }).count(), 0);
await lead.getByRole('button', { name: 'Edit employee permissions', exact: true }).click();
assert.equal(await dialog(lead).getByLabel('View calendar', { exact: true }).isDisabled(), true, 'Viewing is mandatory');
assert.equal(await dialog(lead).getByLabel('Manage team access').count(), 0, 'Administrative permissions are never grantable');
await dialog(lead).getByLabel('Apply without confirmation', { exact: true }).check();
await lead.screenshot({ path: 'test-results/access-permissions.png', fullPage: true });
await save(lead);
await lead.locator('#employee-permissions [data-permission=force_overrides].on').waitFor();
assert.match(await lead.locator('#employee-permissions .info-note').innerText(), /without confirmation/);
const worker = await open();
await signIn(worker, 'carol');
await selectWorkspace(worker, 'Commerce Operations');
assert.equal(await worker.evaluate(() => Alpine.$data(document.body).can('force_overrides')), true, 'The force permission reaches employees');
await openSetup(worker, 'Workspace settings');
assert.equal(await worker.getByRole('button', { name: 'Edit employee permissions', exact: true }).count(), 0, 'Employees cannot edit permissions');
assert.equal(await worker.getByRole('button', { name: 'Edit working interval' }).count(), 0);
assert.equal(await worker.locator('#access-groups').isVisible(), false, 'Group mapping is shown to administrators only');
assert.equal(await worker.getByRole('link', { name: 'Instance', exact: true }).isVisible(), false);
const carol = await apiLogin('carol');
const detail = await carol.get('/api/workspaces/' + commerceId);
const denied = await carol.call('PUT', `/api/workspaces/${commerceId}/member-permissions`, { revision: detail.workspace.revision, permissions: ['view', 'force_overrides', 'edit_schedules'] });
assert.equal(denied.status, 403);
await worker.screenshot({ path: 'test-results/access-employee.png', fullPage: true });
// The change applies at once to everyone: turning it off removes the permission without a new login.
await lead.getByRole('button', { name: 'Edit employee permissions', exact: true }).click();
await dialog(lead).getByLabel('Apply without confirmation', { exact: true }).uncheck();
await save(lead);
await lead.locator('#employee-permissions [data-permission=force_overrides]:not(.on)').waitFor();
await worker.reload();
await worker.waitForFunction(() => { const a = Alpine.$data(document.body); return a.ready && a.workspace && !a.busy; });
await selectWorkspace(worker, 'Commerce Operations');
assert.equal(await worker.evaluate(() => Alpine.$data(document.body).can('force_overrides')), false);
// 5. Workspace isolation: a member of one workspace cannot read another, even by URL or API.
const dave = await open();
await signIn(dave, 'dave');
const visible = await dave.evaluate(() => Alpine.$data(document.body).directoryState.workspaces.map(w => w.name));
assert.deepEqual(visible, ['Acme Engineering']);
assert.equal((await (await apiLogin('dave')).call('GET', '/api/workspaces/' + commerceId)).status, 404);
await dave.goto(`${base}/?workspace=${commerceId}`);
await dave.waitForFunction(() => Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
assert.equal(await dave.evaluate(() => Alpine.$data(document.body).workspace.document.name), 'Acme Engineering');
assert.deepEqual(errors, []);
console.log('PASS: demo login guide, no self-registration, SSO error banner, instance administration and its read-only rules, role-based employee permissions, employee/outsider/isolation checks');
} catch (error) {
console.error('Browser errors:', errors);
for (const [index, page] of pages.entries()) {
console.error('Visible alert:', await page.locator('[role=alert]').allTextContents().catch(() => ''));
await page.screenshot({ path: `test-results/access-failure-${index}.png`, fullPage: true }).catch(() => {});
}
throw error;
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{
for(const [user,names] of [['alice',['Acme Engineering','Customer Support','Regional Platform','Commerce Operations']],['bob',['Acme Engineering','Regional Platform']],['carol',['Acme Engineering','Customer Support','Commerce Operations']],['dave',['Acme Engineering']],['erin',['Acme Engineering']]]){
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
const teams=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces);assert.deepEqual(teams.map(t=>t.name).filter(n=>['Acme Engineering','Customer Support','Regional Platform','Commerce Operations'].includes(n)).sort(),names.sort());
if(user==='alice'){const schedules=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules);assert.equal(schedules.filter(s=>s.type==='priority').length,6);assert(schedules.filter(s=>s.type==='priority').every(s=>s.participants.length>=2&&s.participants.length<=3));assert.equal(schedules.find(s=>s.type==='rotation').participants.length,5);}
if(user==='alice')for(const name of ['Regional Platform','Commerce Operations']){
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -6,7 +6,7 @@ const assert=require('node:assert/strict');
const page=await browser.newPage({viewport:{width:1280,height:950}}),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.MEGURI_URL||'http://127.0.0.1:3000/?view=configuration');
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body),b=a.form.blocks.find(b=>b.kind==='escalation');a.editBinding(b.fields.repeat_count ||= a.literal(0),'number','Repeat count');});
const preview=page.getByRole('textbox',{name:'Value preview',exact:true});await preview.waitFor();assert.equal(await preview.inputValue(),'0');
+7 -14
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
// End-to-end coverage for scheduling contacts, country imports, working hours, and display timezones.
const assert=require('node:assert/strict');
@@ -14,26 +14,19 @@ fs.mkdirSync('test-results',{recursive:true});
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
const state=()=>page.evaluate(()=>{const app=Alpine.$data(document.body);return JSON.parse(JSON.stringify({workspace:app.workspace,user:app.user,endpoint:app.endpoint(),displayZone:app.displayZone}));});
try {
const label='Calendars '+Date.now();
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('calendars-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Calendar tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
// Sandbox 2 belongs to this suite: an administrator plus Bob Chen as an employee.
const tester=await isolatedUser({sandbox:2,prefix:'calendars',display:'Calendar tester',employees:['Bob Chen']});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
await openSetup(page,'Workspace settings');
await page.getByRole('button',{name:'+ Add teammate'}).click();await dialog().getByLabel('Registered username').fill('bob');await save();
await page.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor();
await openSetup(page,'Schedules');
await page.locator('.schedule-create > summary').click();
await page.getByRole('button',{name:'+ Rotation',exact:true}).click();
await dialog().getByLabel('Schedule name').fill('Distributed rotation');
await dialog().getByLabel('Handover time').fill('09:00');
await dialog().getByLabel('Remove participant').first().click();
await dialog().getByLabel('Remove participant').first().click();
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
const picker=()=>page.getByRole('region',{name:'Choose a person'});
await picker().getByLabel('Search workspace users or enter email').fill('Bob');
+5 -12
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn}=require('./support/browser.cjs');
// Regression for dependency-aware removal and routing precedence through the real UI/API.
const assert=require('node:assert/strict');
@@ -24,17 +24,10 @@ fs.mkdirSync('test-results',{recursive:true});
assert.deepEqual((await state()).document.routes.map(r=>r.order),names.map((_,i)=>i));
};
try {
const unique=Date.now();
await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('configuration-'+unique);
await page.getByLabel('Display name',{exact:true}).fill('Configuration tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
// Seed only this test's new team; all mutations under test use visible controls.
const tester=await isolatedUser({sandbox:3,prefix:'configuration',display:'Configuration tester'});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
// Seed only this test's emptied workspace; all mutations under test use visible controls.
await page.evaluate(async()=>{
const app=Alpine.$data(document.body),doc=JSON.parse(JSON.stringify(app.doc));
doc.schedules=['Payments · primary','Payments · secondary','Alternate'].map(name=>({id:crypto.randomUUID(),name,handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}));
+8 -6
View File
@@ -1,4 +1,5 @@
const {isolatedUser}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
@@ -7,19 +8,20 @@ fs.mkdirSync('test-results',{recursive:true});
(async()=>{
const browser=await require('./support/browser.cjs').launchBrowser();
const errors=[];
async function login(name){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('.day').first().waitFor();return page;}
async function login(name,secret){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill(secret);await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.ready&&a.user&&!a.busy&&a.workspace;});return page;}
async function refresh(page){await page.reload();await page.locator('.day').first().waitFor();}
const dialog=p=>p.getByRole('dialog');
const post=(page,path,body)=>page.evaluate(async({path,body})=>{const a=Alpine.$data(document.body);return a.api(path,'POST',body);},{path,body});
let alice,bob;
try{
alice=await login('alice');bob=await login('bob');
// Isolate the workflow from the seeded examples and from other browser suites.
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.username==='bob').user.id};});
const team=await post(alice,'/api/workspaces',{name:'Cover UX '+Date.now()});
// Sandbox 6 belongs to this suite: an administrator (Alice Morgan) and one employee (Bob Chen).
const sandbox=await isolatedUser({sandbox:6,prefix:'cover-lead',display:'Alice Morgan',employees:['Bob Chen']});
const [bobAccount]=sandbox.staff,team=sandbox.workspace;
alice=await login(sandbox.username,sandbox.password);bob=await login(bobAccount.username,bobAccount.password);
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.display_name==='Bob Chen').user.id};});
const endpoint=`/api/workspaces/${team.id}`;
const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);let r=await a.api(endpoint+'/members','PUT',{revision:1,username:'bob',permissions:['view','request_overrides']});const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);const r=(await a.api(endpoint)).workspace;const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const url=base+'/?workspace='+team.id;
await alice.goto(url);await bob.goto(url);await bob.locator('.day').first().waitFor();
assert.equal(await bob.getByRole('button',{name:'Schedules',exact:true}).isVisible(),false,'Setup stays out of the daily flow');
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict');
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
await p.evaluate(()=>{const a=Alpine.$data(document.body),alice=a.user.id,bob=a.people.find(x=>x.display_name==='Bob Chen').id,schedule=a.doc.schedules[0].id; a.doc.requests=[{id:'test',status:'pending',required_approvers:[alice,bob],approvals:[alice],legs:[{schedule_id:schedule,from_user:alice,to_user:bob,start:'2026-10-08T11:00:00+03:00',end:'2026-10-10T08:00:00.000Z'}]}];a.detail.coverage={total:1,issues:[{schedule_id:schedule,user_id:alice,start:'2026-10-08T08:00:00Z',end:'2026-10-11T08:00:00Z'}]};});
const box=p.locator('.coverage-problems');await box.waitFor({state:'visible'});assert.equal(await box.locator('.coverage-issue').count(),2);await box.getByText('Waiting for: Bob Chen',{exact:true}).waitFor();assert.equal(await box.getByText('No substitute assigned',{exact:true}).count(),1);assert.equal(await box.getByText('Awaiting approval',{exact:true}).count(),1);
const parts=await p.evaluate(()=>Alpine.$data(document.body).coverageItems);assert.equal(parts[0].end,parts[1].start);assert.equal(parts[1].start,'2026-10-10T08:00:00.000Z');assert(!parts[1].awaiting);
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,5 +1,5 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({patLine truncated
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true})Line truncated
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=false;});
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+8 -7
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,signIn,demoPassword}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
@@ -13,9 +13,9 @@ fs.mkdirSync('test-results',{recursive:true});
const save=async(name='Save changes')=>{await dialog().getByRole('button',{name,exact:true}).click();await dialog().waitFor({state:'hidden'});};
const checked=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult && !a.generationLoading && a.generationResult.key===JSON.stringify(a.generationPayload());});
try {
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);assert.match(await page.locator('.demo-note').innerText(),/alice[\s\S]*all four workspaces[\s\S]*bob[\s\S]*carol/);
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);for(const [name,role] of [['admin','Instance administrator'],['alice','Workspace administrator'],['bob','Employee']])assert.match(await page.locator(`[data-demo-account=${name}]`).innerText(),new RegExp(role));assert.equal((await page.locator('[data-demo-password]').innerText()).trim(),await demoPassword());
await page.screenshot({path:'test-results/demo-login.png',fullPage:true});
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
const demo=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {matrix:a.directoryState.workspaces.find(t=>t.name==='Acme Engineering'),support:a.directoryState.workspaces.find(t=>t.name==='Customer Support')};});
assert(demo.matrix && demo.support);assert.notEqual(demo.matrix.id,demo.support.id);
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.matrix.id);await openSetup(page,'Variables & rules');await checked();
@@ -40,11 +40,12 @@ fs.mkdirSync('test-results',{recursive:true});
await openSetup(page,'Escalations & routing');await page.screenshot({path:'test-results/support-demo.png',fullPage:true});
// Distinct members cannot read the other example's team, even with its URL.
for(const [username,denied] of [['bob',demo.support],['dave',demo.support]]){
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:'meguri-demo-only'}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:await require('./support/browser.cjs').demoPassword()}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
}
// Mutating UI checks use a separate account, leaving the examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();await page.getByRole('button',{name:'New here? Create an account',exact:true}).click();const unique=Date.now();await page.getByLabel('Username',{exact:true}).fill('matrix-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Matrix tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Matrix '+unique);await save();
// Mutating UI checks use Sandbox 5 and a separate administrator, leaving the demo examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();
const tester=await isolatedUser({sandbox:5,prefix:'matrix',display:'Matrix tester'});
await signIn(page,tester.username,tester.password);await page.getByLabel('Workspace',{exact:true}).waitFor();
await openSetup(page,'Variables & rules');
await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked();
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationCount('create')),2);
+1 -1
View File
@@ -2,7 +2,7 @@ const assert=require('node:assert/strict'),fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();const errors=[];let page;try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
const matrix=await page.evaluate(()=>Alpine.$data(document.body).selected);const support=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.id!==Alpine.$data(document.body).selected).id);
assert.equal(new URL(page.url()).searchParams.get('view'),'configuration');await page.reload();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
await page.getByRole('link',{name:'Requests',exact:true}).click();await page.waitForURL(/view=requests/);await page.getByLabel('Workspace',{exact:true}).selectOption(support);await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,support);assert.equal(new URL(page.url()).searchParams.get('workspace'),support);
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1 +1 @@
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
const assert=require('node:assert/strict');(async()=>{const b=await require('./support/browser.cjs').launchBrowser();try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,7 +1,7 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
const panel=page.locator('.rule-result');const ready=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});await ready();
assert.equal(await panel.locator('.rule-result-row').count(),18);const text=await panel.innerText();assert.match(text,/Payments/);assert.match(text,/Alice Morgan/);assert.match(text,/Time off/);assert.match(text,/Escalation/);assert.match(text,/Routing/);await panel.screenshot({path:'test-results/rule-result.png'});
+1 -1
View File
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -1,3 +1,3 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
+25 -13
View File
@@ -1,4 +1,4 @@
const {openSetup}=require('./support/browser.cjs');
const {openSetup,isolatedUser,demoPassword}=require('./support/browser.cjs');
// Run against an explicitly seeded localhost development instance.
const assert = require('node:assert/strict');
@@ -9,11 +9,11 @@ fs.mkdirSync('test-results', { recursive: true });
(async () => {
const browser = await require('./support/browser.cjs').launchBrowser();
const errors = [];
async function signIn(name) {
async function signIn(name, secret) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message));
await page.goto(base); await page.getByLabel('Username', { exact: true }).fill(name);
await page.getByLabel('Password', { exact: true }).fill('meguri-demo-only');
await page.getByLabel('Password', { exact: true }).fill(secret ?? await demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.getByRole('heading', { name: 'Calendar', exact: true }).waitFor();
return page;
@@ -23,22 +23,30 @@ fs.mkdirSync('test-results', { recursive: true });
await page.getByRole('dialog').waitFor({ state: 'hidden' });
}
try {
const alice = await signIn('alice');
// Workspaces come only from configuration: this suite owns Sandbox 1 (an administrator and one employee).
const label = 'Sandbox 1';
const team1 = await isolatedUser({ sandbox: 1, prefix: 'browser-lead', display: 'Alice Morgan', employees: ['Bob Chen'] });
const [bobAccount] = team1.staff;
const alice = await signIn(team1.username, team1.password);
await alice.locator('.day').first().waitFor();
await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true });
const label = 'Browser ' + Date.now();
await alice.getByRole('button', { name: 'New workspace' }).click();
await alice.getByRole('dialog').getByLabel('Name', { exact: true }).fill(label); await save(alice);
const workspaceId = await alice.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(w=>w.name===label).id, label);
await alice.getByLabel('Workspace', { exact: true }).selectOption(workspaceId);
await alice.waitForFunction(id => Alpine.$data(document.body).workspace?.id===id, workspaceId);
const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; });
assert.deepEqual(Object.keys(created.listing), ['workspaces']);
assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1);
assert.equal(created.record.document.name,label);
assert.equal(created.record.revision,1);
assert.equal('tenant_id' in created.record,false);
assert.equal((await alice.request.get(base+'/api/tenants')).status(),404);
const session0 = await alice.evaluate(() => fetch('/api/session').then(r=>r.json()));
const creation = await alice.request.post(base+'/api/workspaces', { headers: { Origin: base, 'X-CSRF-Token': session0.csrf }, data: { name: 'Nope' } });
assert([404,405].includes(creation.status()), 'Workspaces cannot be created through the API');
await openSetup(alice,'Workspace settings');
await alice.getByRole('button', { name: '+ Add teammate' }).click();
await alice.getByRole('dialog').getByLabel('Registered username').fill('bob'); await save(alice);
await alice.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor();
assert.equal(await alice.getByRole('button', { name: '+ Add teammate' }).count(),0);
assert.equal(await alice.locator(`#workspace-members [data-username=${bobAccount.username}] .role-badge`).innerText(),'Employee');
assert.equal(await alice.locator(`#workspace-members [data-username=${team1.username}] .role-badge`).innerText(),'Workspace administrator');
await openSetup(alice,'Schedules');
await alice.locator('.schedule-create > summary').click();
await alice.getByRole('button', { name: '+ Rotation', exact: true }).click();
@@ -52,11 +60,15 @@ fs.mkdirSync('test-results', { recursive: true });
await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment');
await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click();
await alice.getByRole('dialog').waitFor({state:'hidden'});
const bob = await signIn('bob');
const bob = await signIn(bobAccount.username, bobAccount.password);
const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label);
await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam);
await bob.waitForFunction(id => { const a=Alpine.$data(document.body); return a.workspace?.id===id && !a.busy; }, selectedTeam);
await bob.getByRole('link', { name: /Requests/ }).click();
await bob.getByRole('button', { name: 'Accept', exact: true }).click();
await bob.getByRole('heading', { name: 'Requests', exact: true }).waitFor();
await bob.waitForFunction(() => { const a=Alpine.$data(document.body); return !a.busy && a.needsMe.length>0; });
// The calendar's reply panel holds a hidden twin of this button; target the inbox row's actions.
await bob.locator('.request-card .actions button.primary', { hasText: /^Accept$/ }).click();
await bob.locator('.badge.applied').waitFor();
await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true });
await bob.getByRole('link', { name: 'Calendar', exact: true }).click();
@@ -77,7 +89,7 @@ fs.mkdirSync('test-results', { recursive: true });
await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true });
assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow');
assert.deepEqual(errors, [], 'No browser runtime errors');
console.log('PASS: calendar, workspace creation, membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
console.log('PASS: calendar, configured workspaces (no creation API), role-labelled membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
} catch (error) {
console.error('Browser errors:', errors);
for(const context of browser.contexts()) for(const page of context.pages()) {
+326
View File
@@ -0,0 +1,326 @@
//! Demonstration lifecycle. Kept in its own test binary because installing the demonstration
//! notice is process-wide and disables real provider writes.
mod support;
use chrono::{Duration, Utc};
use cot::db::query;
use meguri::{domain::*, engine, models::SyncJobRow, service};
use uuid::Uuid;
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let source_url = runtime::application_url(url.as_str(), false).unwrap();
let source = Store::connect(&source_url).await.unwrap();
support::install_demo_notice();
let owner = meguri::identity::new_user_row("protected-owner".into(), "Keep me".into(), None);
owner.clone().insert(&source.db).await.unwrap();
let tenant = meguri::identity::ensure_workspace(
&source,
&support::unique_workspace("Protected workspace"),
)
.await
.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
url.set_path(&format!("/{alternate_name}"));
let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str()))
.await
.unwrap();
assert!(isolated);
assert_ne!(
reqwest::Url::parse(&selected).unwrap().path(),
reqwest::Url::parse(&source_url).unwrap().path()
);
let demo = Store::connect(&selected).await.unwrap();
assert!(runtime::is_demo_database(&demo.db).await);
meguri::demo::seed(&demo).await.unwrap();
assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 6);
assert_eq!(
WorkspaceRow::objects().all(&demo.db).await.unwrap().len(),
4
);
let old_user = query!(UserRow,$username=="alice")
.get(&demo.db)
.await
.unwrap()
.unwrap();
demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap();
runtime::reset(&demo.db).await.unwrap();
assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty());
assert!(runtime::is_demo_database(&demo.db).await);
demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap();
let rerun = Store::connect(&selected).await.unwrap();
meguri::demo::seed(&rerun).await.unwrap();
assert_ne!(
query!(UserRow,$username=="alice")
.get(&rerun.db)
.await
.unwrap()
.unwrap()
.id,
old_user.id
);
runtime::mark_normal(&source.db).await.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
assert!(
query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
assert!(
runtime::cleanup(
&source,
Utc::now() + Duration::days(100),
&runtime::Retention::default()
)
.await
.is_err()
);
assert!(
query!(UserRow,$id==owner.id.clone())
.exists(&source.db)
.await
.unwrap()
);
// Names are generated above exclusively for this test; never drop the configured DB.
admin
.raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)"))
.await
.unwrap();
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let url = runtime::application_url(url.as_str(), true).unwrap();
let raw = Database::new(&url).await.unwrap();
runtime::claim_empty(&raw).await.unwrap();
let db = Store::connect(&url).await.unwrap();
support::install_demo_notice();
meguri::demo::seed(&db).await.unwrap();
let alice = query!(UserRow,$username=="alice")
.get(&db.db)
.await
.unwrap()
.unwrap();
let alice_id = Uuid::parse_str(&alice.id).unwrap();
// A temporary workspace and account next to the permanent examples, both granted through
// the ordinary group mapping.
let mut config = meguri::demo::config().unwrap();
let temporary = support::unique_workspace("Temporary team");
config.workspaces.push(temporary.clone());
meguri::identity::ensure_workspace(&db, &temporary)
.await
.unwrap();
let visitor =
support::account(&db, &config, "Temporary visitor", &temporary.admin_groups).await;
let mut alice_groups = meguri::identity::stored_groups(&alice).unwrap();
alice_groups.extend(temporary.member_groups.clone());
meguri::identity::apply_access(&db, &config, alice_id, Some(alice_groups))
.await
.unwrap();
let mut team = meguri::identity::find_workspace(&db, &temporary.key)
.await
.unwrap()
.unwrap();
let tenant = team.clone();
let original_revision = team.revision;
// Editing does not extend lifetime: created_at is the expiration anchor.
let mut doc = team.document.clone();
doc.name = "Edited temporary team".into();
team = service::update_document(&db, visitor.id, team.id, team.revision, doc)
.await
.unwrap();
assert!(team.revision > original_revision);
let now = query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.created_at;
let schedule = Uuid::new_v4();
let mut document = team.document.clone();
document.schedules.push(Schedule {
id: schedule,
name: "Temporary rotation".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![visitor.id],
anchor: now.date_naive(),
shift_days: 7,
},
});
team = service::update_document(&db, visitor.id, team.id, team.revision, document)
.await
.unwrap();
let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap();
team = service::request_replacement(
&db,
visitor.id,
team.id,
service::NewRequest {
remaining: false,
revision: team.revision,
reason: "Demo coverage".into(),
force: false,
legs: vec![ReplacementLeg {
schedule_id: schedule,
start,
end: start + Duration::hours(1),
from_user: visitor.id,
to_user: alice_id,
}],
},
)
.await
.unwrap();
let ttl = runtime::Retention {
workspaces: 120,
accounts: 300,
requests: 60,
events: 180,
purge_grace: 30,
};
runtime::cleanup(&db, now + Duration::seconds(61), &ttl)
.await
.unwrap();
let after = db.workspace(team.id).await.unwrap();
assert!(after.document.requests.is_empty());
assert_eq!(after.revision, team.revision + 1);
assert_eq!(
query!(SyncJobRow,$workspace_id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.requested_revision,
after.revision
);
runtime::cleanup(&db, now + Duration::seconds(119), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_ok());
runtime::cleanup(&db, now + Duration::seconds(121), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_err());
assert!(
query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.demo_deleted_at
.is_some()
);
assert!(
service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
service::update_document(
&db,
visitor.id,
team.id,
team.revision,
team.document.clone()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(152), &ttl)
.await
.unwrap();
assert!(
!query!(WorkspaceRow,$id==team.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
runtime::cleanup(&db, now + Duration::seconds(242), &ttl)
.await
.unwrap();
assert!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
runtime::cleanup(&db, now + Duration::seconds(301), &ttl)
.await
.unwrap();
assert!(
service::login(
&db,
true,
visitor.username.clone(),
"test-only-password".into()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(332), &ttl)
.await
.unwrap();
assert!(
!query!(UserRow,$id==visitor.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert!(
!query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 6);
meguri::demo::seed(&db).await.unwrap();
assert_eq!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4);
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
+2
View File
@@ -121,6 +121,7 @@ fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
email: None,
display_name: "Person".into(),
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
aliases: vec![],
}));
doc.holidays.push(Holiday {
country: "CY".into(),
@@ -168,6 +169,7 @@ fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
email: Some("cover@example.com".into()),
display_name: "Cover".into(),
country: Some("CY".into()),
aliases: vec![],
});
doc.requests.push(ReplacementRequest {
effective_legs: None,
+25 -327
View File
@@ -14,323 +14,6 @@ use serde_json::json;
use support::Fixture;
use uuid::Uuid;
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_bootstrap_protects_unmarked_and_normal_databases_and_resets_only_application_state() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let source_url = runtime::application_url(url.as_str(), false).unwrap();
let source = Store::connect(&source_url).await.unwrap();
let owner = service::register(
&source,
"protected-owner".into(),
"Keep me".into(),
"test-password-only".into(),
)
.await
.unwrap();
let tenant = service::create_workspace(&source, owner.id, "Protected workspace".into())
.await
.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
let alternate_name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
url.set_path(&format!("/{alternate_name}"));
let (selected, isolated) = runtime::select_database(&source_url, Some(url.as_str()))
.await
.unwrap();
assert!(isolated);
assert_ne!(
reqwest::Url::parse(&selected).unwrap().path(),
reqwest::Url::parse(&source_url).unwrap().path()
);
let demo = Store::connect(&selected).await.unwrap();
assert!(runtime::is_demo_database(&demo.db).await);
meguri::demo::seed(&demo).await.unwrap();
assert_eq!(UserRow::objects().all(&demo.db).await.unwrap().len(), 5);
assert_eq!(
WorkspaceRow::objects().all(&demo.db).await.unwrap().len(),
4
);
let old_user = query!(UserRow,$username=="alice")
.get(&demo.db)
.await
.unwrap()
.unwrap();
demo.db.raw("DO $$ BEGIN CREATE TABLE unrelated_sentinel(value text); INSERT INTO unrelated_sentinel VALUES('keep'); END $$").await.unwrap();
runtime::reset(&demo.db).await.unwrap();
assert!(UserRow::objects().all(&demo.db).await.unwrap().is_empty());
assert!(runtime::is_demo_database(&demo.db).await);
demo.db.raw("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM unrelated_sentinel WHERE value='keep') THEN RAISE EXCEPTION 'unrelated data lost'; END IF; END $$").await.unwrap();
let rerun = Store::connect(&selected).await.unwrap();
meguri::demo::seed(&rerun).await.unwrap();
assert_ne!(
query!(UserRow,$username=="alice")
.get(&rerun.db)
.await
.unwrap()
.unwrap()
.id,
old_user.id
);
runtime::mark_normal(&source.db).await.unwrap();
assert!(!runtime::is_demo_database(&source.db).await);
assert!(runtime::reset(&source.db).await.is_err());
assert!(
query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
assert!(
runtime::cleanup(
&source,
Utc::now() + Duration::days(100),
&runtime::Retention::default()
)
.await
.is_err()
);
assert!(
query!(UserRow,$id==owner.id.to_string())
.exists(&source.db)
.await
.unwrap()
);
// Names are generated above exclusively for this test; never drop the configured DB.
admin
.raw(&format!("DROP DATABASE {alternate_name} WITH (FORCE)"))
.await
.unwrap();
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB for isolated lifecycle tests"]
async fn demo_retention_soft_deletes_then_purges_by_creation_and_reseeds_examples() {
use cot::db::{Database, Model};
use meguri::{
demo_runtime as runtime,
models::{UserRow, WorkspaceRow},
store::Store,
};
let admin = Database::new(std::env::var("MEGURI_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let name = format!("meguri_demo_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&std::env::var("MEGURI_TEST_DATABASE_URL").unwrap()).unwrap();
url.set_path(&format!("/{name}"));
let url = runtime::application_url(url.as_str(), true).unwrap();
let raw = Database::new(&url).await.unwrap();
runtime::claim_empty(&raw).await.unwrap();
let db = Store::connect(&url).await.unwrap();
meguri::demo::seed(&db).await.unwrap();
let alice = query!(UserRow,$username=="alice")
.get(&db.db)
.await
.unwrap()
.unwrap();
let alice_id = Uuid::parse_str(&alice.id).unwrap();
let visitor = service::register(
&db,
"visitor".into(),
"Temporary visitor".into(),
"test-password-only".into(),
)
.await
.unwrap();
let mut team = service::create_workspace(&db, visitor.id, "Temporary team".into())
.await
.unwrap();
let tenant = team.clone();
let original_revision = team.revision;
// Editing does not extend lifetime: created_at is the expiration anchor.
let mut doc = team.document.clone();
doc.name = "Edited temporary team".into();
team = service::update_document(&db, visitor.id, team.id, team.revision, doc)
.await
.unwrap();
assert!(team.revision > original_revision);
let now = query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.created_at;
team = service::set_member(
&db,
visitor.id,
team.id,
team.revision,
"alice".into(),
Permission::member(),
)
.await
.unwrap();
let schedule = Uuid::new_v4();
let mut document = team.document.clone();
document.schedules.push(Schedule {
id: schedule,
name: "Temporary rotation".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![visitor.id],
anchor: now.date_naive(),
shift_days: 7,
},
});
team = service::update_document(&db, visitor.id, team.id, team.revision, document)
.await
.unwrap();
let start = engine::handover(&team.document, now.date_naive() + Duration::days(2)).unwrap();
team = service::request_replacement(
&db,
visitor.id,
team.id,
service::NewRequest {
remaining: false,
revision: team.revision,
reason: "Demo coverage".into(),
force: false,
legs: vec![ReplacementLeg {
schedule_id: schedule,
start,
end: start + Duration::hours(1),
from_user: visitor.id,
to_user: alice_id,
}],
},
)
.await
.unwrap();
let ttl = runtime::Retention {
workspaces: 120,
accounts: 300,
requests: 60,
events: 180,
purge_grace: 30,
};
runtime::cleanup(&db, now + Duration::seconds(61), &ttl)
.await
.unwrap();
let after = db.workspace(team.id).await.unwrap();
assert!(after.document.requests.is_empty());
assert_eq!(after.revision, team.revision + 1);
assert_eq!(
query!(SyncJobRow,$workspace_id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.requested_revision,
after.revision
);
runtime::cleanup(&db, now + Duration::seconds(119), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_ok());
runtime::cleanup(&db, now + Duration::seconds(121), &ttl)
.await
.unwrap();
assert!(db.workspace(team.id).await.is_err());
assert!(
query!(WorkspaceRow,$id==team.id.to_string())
.get(&db.db)
.await
.unwrap()
.unwrap()
.demo_deleted_at
.is_some()
);
assert!(
service::list_workspaces(&db, visitor.id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
service::update_document(
&db,
visitor.id,
team.id,
team.revision,
team.document.clone()
)
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(152), &ttl)
.await
.unwrap();
assert!(
!query!(WorkspaceRow,$id==team.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
runtime::cleanup(&db, now + Duration::seconds(242), &ttl)
.await
.unwrap();
assert!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.is_empty()
);
runtime::cleanup(&db, now + Duration::seconds(301), &ttl)
.await
.unwrap();
assert!(
service::login(&db, "visitor".into(), "test-password-only".into())
.await
.is_err()
);
runtime::cleanup(&db, now + Duration::seconds(332), &ttl)
.await
.unwrap();
assert!(
!query!(UserRow,$id==visitor.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert!(
!query!(WorkspaceRow,$id==tenant.id.to_string())
.exists(&db.db)
.await
.unwrap()
);
assert_eq!(UserRow::objects().all(&db.db).await.unwrap().len(), 5);
meguri::demo::seed(&db).await.unwrap();
assert_eq!(
service::list_workspaces(&db, alice_id).await.unwrap()["workspaces"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(WorkspaceRow::objects().all(&db.db).await.unwrap().len(), 4);
admin
.raw(&format!("DROP DATABASE {name} WITH (FORCE)"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn generation_is_workspace_scoped_atomic_and_revision_checked() {
@@ -369,9 +52,9 @@ async fn generation_is_workspace_scoped_atomic_and_revision_checked() {
.is_err()
);
draft.review_id = preview["review_id"].as_str().map(str::to_owned);
let other = service::create_workspace(&f.db, actor, "Independent workspace".into())
.await
.unwrap();
let other = f
.extra_workspace("Independent workspace", &f.people[0])
.await;
assert!(
generation_service::preview(&f.db, f.people[1].id, other.id, draft.clone())
.await
@@ -844,6 +527,7 @@ async fn email_contacts_are_scoped_people_without_login_or_access_and_import_is_
display_name: "New colleague".into(),
email: Some(" New.Person@Example.com ".into()),
country: Some("am".into()),
aliases: vec![],
});
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
participants.push(id);
@@ -954,9 +638,7 @@ async fn workspace_interval_propagates_atomically_and_user_timezone_does_not_mov
let actor = f.people[0].id;
let tenant = f.team.id;
let team = f.team.id;
let other = service::create_workspace(&f.db, actor, "Another team".into())
.await
.unwrap();
let other = f.extra_workspace("Another team", &f.people[0]).await;
let mut doc = f.team.document.clone();
doc.routes.push(RoutingRule {
id: Uuid::new_v4(),
@@ -1106,6 +788,7 @@ async fn jsm_resolves_exact_active_email_before_writes_and_explicit_ids_override
display_name: "Email contact".into(),
email: Some("new@example.com".into()),
country: Some("CY".into()),
aliases: vec![],
});
f.team.document.schedules[0].kind = ScheduleKind::Rotation {
participants: vec![contact],
@@ -1188,9 +871,7 @@ async fn approval_swap_tenant_isolation_and_atomic_outbox() {
.await
.is_err()
);
let other = service::create_workspace(&f.db, owner, "Private workspace".into())
.await
.unwrap();
let other = f.extra_workspace("Private workspace", &f.people[0]).await;
assert!(f.db.require(other.id, bob, Permission::View).await.is_err());
assert!(matches!(
service::update_document(
@@ -1304,6 +985,16 @@ async fn concurrent_edits_and_constraint_failure_preserve_history_and_job() {
let before = f.db.audit(team).await.unwrap().len();
let mut invalid = record.clone();
invalid.document.name = "Must roll back".into();
// A history row that already owns the next revision's key makes the final statement fail
// after the workspace update and job upsert were evaluated: everything must roll back.
let planted = format!("{team}:{}", record.revision + 1);
f.db.db
.raw_with(
"INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) VALUES($1,$2,$3,'{}',now())",
&[&planted, &team.to_string(), &(record.revision + 1)],
)
.await
.unwrap();
assert!(
f.db.commit(
&mut invalid,
@@ -1311,11 +1002,18 @@ async fn concurrent_edits_and_constraint_failure_preserve_history_and_job() {
Permission::ManageMembers,
"test.invalid",
json!({}),
Some((Uuid::new_v4(), Permission::member()))
Some(Permission::member())
)
.await
.is_err()
);
f.db.db
.raw_with(
"DELETE FROM meguri__workspace_history WHERE id=$1",
&[&planted],
)
.await
.unwrap();
assert_eq!(
f.db.workspace(team).await.unwrap().revision,
record.revision
+434
View File
@@ -0,0 +1,434 @@
//! End-to-end SSO over HTTP: the real Cot application, a real PostgreSQL database and a fake
//! OpenID provider. One test function because the instance configuration is process-wide.
#[path = "support/fake_oidc.rs"]
mod fake_oidc;
mod support;
use cot::{
AppBuilder,
config::{DatabaseConfig, ProjectConfig},
db::query,
project::RegisterAppsContext,
test::TestServerBuilder,
};
use fake_oidc::{CLIENT_ID, CLIENT_SECRET, Scenario};
use meguri::{
access::{self, Config},
identity::{self, BootstrapPasswords, SyncOptions},
models::{ExternalIdentityRow, UserRow},
oidc,
web::MeguriApp,
};
use reqwest::{Client, Response, StatusCode, redirect::Policy};
use serde_json::{Value, json};
use support::IsolatedDatabase;
struct TestProject {
database: String,
}
impl cot::Project for TestProject {
fn config(&self, _: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder()
.debug(false)
.database(DatabaseConfig::builder().url(self.database.clone()).build())
.build())
}
fn register_apps(&self, apps: &mut AppBuilder, _: &RegisterAppsContext) {
apps.register_with_views(MeguriApp, "");
}
}
fn cookies(response: &Response) -> Vec<String> {
response
.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap().to_owned())
.collect()
}
fn cookie_value(response: &Response, name: &str) -> Option<String> {
cookies(response).iter().find_map(|c| {
let value = c.strip_prefix(&format!("{name}="))?.split(';').next()?;
(!value.is_empty()).then(|| value.to_owned())
})
}
fn location(response: &Response) -> String {
response
.headers()
.get("location")
.expect("redirect location")
.to_str()
.unwrap()
.to_owned()
}
fn claims(subject: &str, name: &str, groups: &[&str]) -> Value {
json!({
"sub":subject,"preferred_username":name,"name":format!("{name} Person"),
"email":format!("{name}@corp.test"),"groups":groups
})
}
struct Harness {
client: Client,
base: String,
idp: fake_oidc::Server,
}
impl Harness {
async fn begin(&self) -> (String, String) {
let response = self
.client
.get(format!("{}/api/oidc/login", self.base))
.send()
.await
.unwrap();
assert!(response.status().is_redirection(), "{}", response.status());
let flow = cookies(&response)
.into_iter()
.find(|c| c.starts_with("meguri_oidc="))
.unwrap();
for attribute in ["HttpOnly", "SameSite=Lax", "Path=/api/oidc", "Max-Age=600"] {
assert!(flow.contains(attribute), "{flow}");
}
let state = flow["meguri_oidc=".len()..].split(';').next().unwrap();
(location(&response), state.to_owned())
}
async fn callback(&self, query: &str, cookie: Option<&str>) -> Response {
let mut request = self
.client
.get(format!("{}/api/oidc/callback?{query}", self.base));
if let Some(cookie) = cookie {
request = request.header("cookie", format!("meguri_oidc={cookie}"));
}
request.send().await.unwrap()
}
async fn attempt(&self, scenario: Scenario) -> Response {
let (url, state) = self.begin().await;
let code = self.idp.authorize(&url, scenario);
self.callback(&format!("code={code}&state={state}"), Some(&state))
.await
}
async fn session(&self, token: &str) -> Value {
self.client
.get(format!("{}/api/session", self.base))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
async fn signed_in(&self, response: &Response) -> String {
assert!(response.status().is_redirection());
assert_eq!(location(response), "/", "{:?}", cookies(response));
cookie_value(response, "meguri_session").expect("session cookie")
}
fn refused(&self, response: &Response, code: &str) -> String {
assert!(response.status().is_redirection());
let target = location(response);
assert!(
target.starts_with(&format!("/?login_error={code}&ref=")),
"{target}"
);
assert!(
cookie_value(response, "meguri_session").is_none(),
"a refused sign-in never sets a session"
);
target.split("ref=").nth(1).unwrap().to_owned()
}
}
#[cot::e2e_test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"]
async fn sso_login_over_http_validates_tokens_maps_groups_and_never_leaks_a_session() {
let iso = IsolatedDatabase::new().await;
let idp = fake_oidc::Server::start().await;
let project = TestProject {
database: iso.url().await,
};
let server = TestServerBuilder::new(project).start().await;
let base = server.url();
// The process-wide settings can only be known once both servers have addresses.
// SAFETY: this binary has a single test function; nothing else reads the environment.
unsafe {
std::env::set_var("MEGURI_PUBLIC_URL", &base);
}
let config = Config::parse(&format!(
r#"
oidc: {{issuer: '{}', client_id: {CLIENT_ID}}}
instance_admin_groups: ['/meguri/admins']
workspaces:
- {{key: payments, name: Payments, admin_groups: ['/payments/leads'], member_groups: ['/payments/team']}}
- {{key: search, name: Search, admin_groups: ['/search/leads'], member_groups: ['/search/team']}}
"#,
idp.issuer()
))
.unwrap();
access::install(config.clone());
oidc::install(oidc::Settings {
config: config.oidc.clone().unwrap(),
client_secret: CLIENT_SECRET.into(),
redirect_uri: format!("{base}/api/oidc/callback"),
});
identity::sync_config(
&iso.store,
&config,
SyncOptions {
passwords: BootstrapPasswords::Derived,
now: chrono::Utc::now(),
allow_disable_all: false,
},
)
.await
.unwrap();
let h = Harness {
client: Client::builder().redirect(Policy::none()).build().unwrap(),
base: base.clone(),
idp,
};
let users = || async { UserRow::objects_count(&iso).await };
// The login screen offers SSO only; password sign-in is switched off for this instance.
let session: Value = h
.client
.get(format!("{base}/api/session"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(session["auth"], json!({"oidc":true,"password":false}));
assert!(session["user"].is_null());
let password = h
.client
.post(format!("{base}/api/login"))
.header("origin", &base)
.json(&json!({"username":"anyone","password":"anything-at-all"}))
.send()
.await
.unwrap();
assert_eq!(password.status(), StatusCode::UNAUTHORIZED);
// Registration and workspace creation no longer exist.
for (path, expected) in [("/api/register", StatusCode::NOT_FOUND)] {
let response = h
.client
.post(format!("{base}{path}"))
.header("origin", &base)
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}");
}
// Happy path: an employee of Payments signs in and gets exactly that.
let (url, _) = h.begin().await;
let url = reqwest::Url::parse(&url).unwrap();
assert_eq!(url.path(), "/realms/corp/protocol/openid-connect/auth");
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["redirect_uri"], format!("{base}/api/oidc/callback"));
assert!(query["state"].len() >= 32 && query["nonce"].len() >= 32);
let response = h
.attempt(Scenario::new(claims(
"u-1",
"carol",
&["/payments/team", "/other"],
)))
.await;
let token = h.signed_in(&response).await;
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_session=")
&& c.contains("HttpOnly")
&& c.contains("SameSite=Lax"))
);
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_oidc=;") && c.contains("Max-Age=0")),
"the one-time flow cookie is cleared"
);
let session = h.session(&token).await;
assert_eq!(session["user"]["username"], "carol");
assert_eq!(session["user"]["email"], "carol@corp.test");
assert_eq!(session["user"]["instance_admin"], false);
assert_eq!(session["user"]["has_password"], false);
let workspaces: Value = h
.client
.get(format!("{base}/api/workspaces"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(workspaces["workspaces"].as_array().unwrap().len(), 1);
assert_eq!(workspaces["workspaces"][0]["key"], "payments");
// The administration API answers 403 to an ordinary employee.
let admin = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(admin.status(), StatusCode::FORBIDDEN);
assert_eq!(users().await, 1);
// Instance administrators and workspace leads come from their groups.
let response = h
.attempt(Scenario::new(claims("u-2", "root", &["/meguri/admins"])))
.await;
let admin_token = h.signed_in(&response).await;
assert_eq!(
h.session(&admin_token).await["user"]["instance_admin"],
true
);
let overview: Value = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(overview["workspaces"].as_array().unwrap().len(), 2);
assert_eq!(overview["config"]["oidc_issuer"], idp_issuer(&h));
// Groups can come from userinfo when the ID token does not carry them.
let mut scenario =
Scenario::new(json!({"sub":"u-3","preferred_username":"lena","email":"lena@corp.test"}));
scenario.userinfo = Some(json!({"sub":"u-3","groups":["/search/leads"]}));
let response = h.attempt(scenario).await;
let lena = h.signed_in(&response).await;
assert_eq!(h.session(&lena).await["user"]["username"], "lena");
assert_eq!(h.idp.state.lock().unwrap().userinfo_requests, 1);
// Unmapped users and a provider that sends no groups get the same explanation and no account.
let before = users().await;
let reference = h.refused(
&h.attempt(Scenario::new(claims("u-4", "stranger", &["/unknown"])))
.await,
"no_access",
);
assert_eq!(reference.len(), 8);
h.refused(
&h.attempt(Scenario::new(
json!({"sub":"u-5","preferred_username":"nogroups"}),
))
.await,
"no_access",
);
assert_eq!(users().await, before);
// Everything that can be wrong with a token or callback is refused without a session.
let mut wrong_issuer = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_issuer.issuer = Some("https://evil.example/realms/corp".into());
let mut wrong_audience = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_audience.audience = Some(json!("another-client"));
let mut expired = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
expired.expires_in = -600;
let mut wrong_nonce = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_nonce.nonce = Some("replayed-nonce".into());
let mut bad_signature = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
bad_signature.corrupt_signature = true;
for scenario in [
wrong_issuer,
wrong_audience,
expired,
wrong_nonce,
bad_signature,
] {
h.refused(&h.attempt(scenario).await, "failed");
}
let mut broken = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
broken.token_status = 500;
h.refused(&h.attempt(broken).await, "failed");
assert!(
!query!(ExternalIdentityRow,$subject=="u-6".to_owned())
.exists(&iso.store.db)
.await
.unwrap()
);
// Flow protections: state cookie binding, one-time use, missing and foreign state.
let good = || Scenario::new(claims("u-7", "dave", &["/payments/team"]));
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(
&format!("code={code}&state={state}"),
Some("0".repeat(64).as_str()),
)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), None)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.signed_in(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
)
.await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
"failed",
);
h.refused(
&h.callback("error=access_denied&state=x", Some("x")).await,
"failed",
);
h.refused(&h.callback("", None).await, "failed");
// A user whose groups were revoked is refused at the next sign-in and loses access at once.
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/search/team"])))
.await;
h.signed_in(&response).await;
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/unknown"])))
.await;
h.refused(&response, "no_access");
let row = query!(UserRow,$username=="carol".to_owned())
.get(&iso.store.db)
.await
.unwrap()
.unwrap();
let id = row.id;
assert!(
!query!(meguri::models::WorkspaceMemberRow,$user_id==id)
.exists(&iso.store.db)
.await
.unwrap()
);
drop(h);
server.close().await;
iso.drop().await;
}
fn idp_issuer(h: &Harness) -> String {
h.idp.issuer()
}
trait Count {
async fn objects_count(iso: &IsolatedDatabase) -> usize;
}
impl Count for UserRow {
async fn objects_count(iso: &IsolatedDatabase) -> usize {
use cot::db::Model;
UserRow::objects().all(&iso.store.db).await.unwrap().len()
}
}
+65 -1
View File
@@ -2,14 +2,78 @@ const { chromium } = require('playwright');
function launchBrowser() {
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
return chromium.launch({
headless: true,
...(channel === 'chromium' ? {} : { channel }),
...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
});
}
exports.launchBrowser = launchBrowser;
const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
exports.baseUrl = baseUrl;
// The demo generates one password per server start and publishes it to the login screen.
let password;
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(session => {
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
return session.demo_lifecycle.password;
});
exports.signIn = async (page, username, secret) => {
await page.goto(baseUrl());
await page.getByLabel('Username', { exact: true }).fill(username);
await page.getByLabel('Password', { exact: true }).fill(secret ?? await exports.demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForFunction(() => { const a = window.Alpine && Alpine.$data(document.body); return a && a.ready && a.user && !a.busy; });
};
// Minimal cookie-aware JSON client for test setup that must not go through the UI.
exports.apiLogin = async (username, secret) => {
const base = baseUrl();
const login = await fetch(base + '/api/login', { method: 'POST', headers: { Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: secret ?? await exports.demoPassword() }) });
if (!login.ok) throw new Error(`Login as ${username} failed: ${login.status}`);
const cookie = login.headers.getSetCookie().map(c => c.split(';')[0]).find(c => c.startsWith('meguri_session='));
const { csrf } = await login.json();
const call = async (method, path, body) => {
const response = await fetch(base + path, { method, headers: { Origin: base, 'Content-Type': 'application/json', Cookie: cookie, 'X-CSRF-Token': csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await response.json().catch(() => ({}));
return { status: response.status, data };
};
const ok = async (method, path, body) => {
const result = await call(method, path, body);
if (result.status >= 300) throw new Error(`${method} ${path} -> ${result.status} ${JSON.stringify(result.data)}`);
return result.data;
};
return { call, ok, get: path => ok('GET', path), post: (path, body) => ok('POST', path, body), put: (path, body) => ok('PUT', path, body), csrf, cookie };
};
exports.adminApi = () => exports.apiLogin('admin');
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display }) => {
const overview = await admin.get('/api/admin/overview');
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
const secret = 'local-browser-test-password';
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups });
return { username, password: secret, display, workspace: target };
};
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
exports.isolatedUser = async ({ sandbox, prefix, display, employees = [] }) => {
const admin = await exports.adminApi();
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display });
const staff = [];
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
return { admin, ...user, staff };
};
exports.openSetup = async (page, name) => {
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
+293
View File
@@ -0,0 +1,293 @@
//! A minimal OpenID provider for tests: discovery, JWKS, token and userinfo endpoints with
//! RS256 tokens. The private key is a throwaway test fixture and protects nothing.
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
const PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDI4xpMobGZ91uK
+uV7YFtGg0KorieswaP4ZhIqg3LkxIjpaMggyw0Fc4jliFPxn+uZAzKEF5lBrrdt
XBllxKayC6dLEHFBvrglZMwyzsj6fgssne/N1YRWsQsQ48d2KANzfGpkBB8/29g7
YhpNK6lWZo54yGFcFa2g68/yqbpQ6dSo191/CSqOnC+R2pdMe9NzI8kQBtZPkcft
hmQ05IQJoyNHgBgwUQO2vB7J23haATlvQUrZlv0ASFYgxJDKeLZOPvdLGEmezIvc
WZYdI5qEqiNDr9KRWLS/sDYxxOQkVxRP4OoWy5svru1ptJ3QEG50uUnPZkdpX/Ny
UaMFOieJAgMBAAECgf8UyqFa5XWp/ODExbcM7nE2ydLmiAuRJgvXmHfdLY72qqne
ajvq17OcvVVs3g3B3wE5PaKWPCAdhJdEtUCCB/HXTO25hOTOYAhTrdLuh6cvIUxF
wqlJGVNM7tM3ZS8mW8b7maLjMxQtep605CxYxjU5c+wD54yuIMkl58Q3bLENuM5G
Y5GeXwmAv4yrJXuFIe/h2F6A+p4IEQ64QF6VQ/rDeG8kniLXWNZjAndqGxPdLDjC
4YdXpSWeYM/dTGKUyt0SwE64vxytMNyoRWAO4i4v3JJbrST0SmAM2RwM803Ks0Ys
H9VvFoYt5kGhETOz4OKa3m6HrMwdUy7vnUYd870CgYEA59CLNpjU3Dl57RJRlHRw
2FAPM0uM9qBWiahDyhQAeHeypUFGeu1yZHZCH9Z5oOo28xRyAB27R8ozFX0JdPYl
HBuJH63boff4/DuzjmnAK92889m+cbyPcAd6BjOo545cMRiAXvu3b8cz2Mlks1bj
uWS5RIVbQvuiPqNEbHNHc5UCgYEA3diG58Ziug75V6k8U9wHyqWWRyvQdwdJQmey
FAB0LQdSsHUkPteGl2U6Ml7EL2AuqoZK5kVRK+8aGhBHyOlsjwj/sCfQfiTNDgve
MP7pWfUUjctO8ZvKHG3SG87BG2kLAW6l/vuhDfLwQe/PV8hC0yDegFfF1R4nQVYf
7XFv5yUCgYBftygEHw6k78Z8MLgmh/2gE05za5qEqqeQpz12TtPy6dmFinJDh49Y
E8wxwSjvXEXcEHERwzT063Q0RzCEbq3b8TKpNewd8HmKDEyTXO7QLfo65YlRBP5k
z3F+5g2zroA6dVdr4JtZN/Mca+t8cUAN6Jzmwflwqt3O5zL1ZJP4EQKBgQCt50Sp
f9x4mbBgRWvjGQKKmo9ZL0F1bSLCiWMRRuHLQohRhyJomK8LEvEx9xO3R6CNUER6
ii/9TquZbtW26zSMU7OLtUo8+EA+GJOOuOTX/cSk+pDh2QvwiS5BWiwKfqd/8ery
HPK9aoloRsK0v2mS85s35JZSYBbJoufYTCo6eQKBgGW1vIlLzHRMeboj82uW0yw2
Jkg6Ig45qtVhY89267E8cI7gdC92Y715nFR4v+0eiWo08k+FIaHyBsah0vklj1Q6
fj6ir9inIzkkrFmjs6u/g4h2GNGUXOJJwJzOU78C6oeB3G5OUM/pWFx0zypRGH/q
HpM1TJ5Fkyog20WBQS9+
-----END PRIVATE KEY-----";
const MODULUS: &str = "yOMaTKGxmfdbivrle2BbRoNCqK4nrMGj-GYSKoNy5MSI6WjIIMsNBXOI5YhT8Z_rmQMyhBeZQa63bVwZZcSmsgunSxBxQb64JWTMMs7I-n4LLJ3vzdWEVrELEOPHdigDc3xqZAQfP9vYO2IaTSupVmaOeMhhXBWtoOvP8qm6UOnUqNfdfwkqjpwvkdqXTHvTcyPJEAbWT5HH7YZkNOSECaMjR4AYMFEDtrweydt4WgE5b0FK2Zb9AEhWIMSQyni2Tj73SxhJnsyL3FmWHSOahKojQ6_SkVi0v7A2McTkJFcUT-DqFsubL67tabSd0BBudLlJz2ZHaV_zclGjBToniQ";
pub const CLIENT_ID: &str = "meguri-test";
pub const CLIENT_SECRET: &str = "test-client-secret";
/// What a single sign-in attempt looks like to the relying party.
#[derive(Clone)]
pub struct Scenario {
pub claims: Value,
pub userinfo: Option<Value>,
pub issuer: Option<String>,
pub audience: Option<Value>,
pub expires_in: i64,
pub nonce: Option<String>,
pub corrupt_signature: bool,
pub token_status: u16,
}
impl Scenario {
pub fn new(claims: Value) -> Self {
Self {
claims,
userinfo: None,
issuer: None,
audience: None,
expires_in: 300,
nonce: None,
corrupt_signature: false,
token_status: 200,
}
}
}
struct Grant {
scenario: Scenario,
nonce: String,
challenge: String,
}
#[derive(Default)]
pub struct State {
codes: HashMap<String, Grant>,
tokens: HashMap<String, Value>,
pub token_requests: usize,
pub userinfo_requests: usize,
}
pub struct Server {
pub base: String,
pub state: Arc<Mutex<State>>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
impl Server {
pub fn issuer(&self) -> String {
format!("{}/realms/corp", self.base)
}
/// Registers an authorization the way a browser visit to the provider would.
pub fn authorize(&self, authorization_url: &str, scenario: Scenario) -> String {
let url = reqwest::Url::parse(authorization_url).unwrap();
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["response_type"], "code");
assert_eq!(query["client_id"], CLIENT_ID);
assert_eq!(query["code_challenge_method"], "S256");
assert!(query["scope"].split(' ').any(|s| s == "openid"));
let code = format!("code-{}", uuid::Uuid::new_v4().simple());
self.state.lock().unwrap().codes.insert(
code.clone(),
Grant {
scenario,
nonce: query["nonce"].clone(),
challenge: query["code_challenge"].clone(),
},
);
code
}
pub async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let state = Arc::new(Mutex::new(State::default()));
let (shared, root) = (state.clone(), base.clone());
let task = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let (shared, root) = (shared.clone(), root.clone());
tokio::spawn(async move {
let mut buffer = vec![];
let (head_end, length) = loop {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buffer[..end]);
let length = head
.lines()
.find_map(|l| {
l.to_lowercase()
.strip_prefix("content-length: ")
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(0);
break (end + 4, length);
}
};
while buffer.len() < head_end + length {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&buffer[..head_end]).to_string();
let body =
String::from_utf8_lossy(&buffer[head_end..head_end + length]).to_string();
let mut first = head.lines().next().unwrap().split_whitespace();
let method = first.next().unwrap().to_owned();
let path = first.next().unwrap().to_owned();
let (status, result) = handle(
&mut shared.lock().unwrap(),
&root,
&method,
&path,
&head,
&body,
);
let body = result.to_string();
let response = format!(
"HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
});
}
});
Self { base, state, task }
}
}
fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> {
head.lines().find_map(|l| {
let (key, value) = l.split_once(':')?;
key.eq_ignore_ascii_case(name).then(|| value.trim())
})
}
fn sign(claims: &Value, corrupt: bool) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key".into());
let mut token = jsonwebtoken::encode(
&header,
claims,
&EncodingKey::from_rsa_pem(PRIVATE_KEY.as_bytes()).unwrap(),
)
.unwrap();
if corrupt {
let last = token.pop().unwrap();
token.push(if last == 'A' { 'B' } else { 'A' });
}
token
}
fn handle(
s: &mut State,
root: &str,
method: &str,
path: &str,
head: &str,
body: &str,
) -> (u16, Value) {
let issuer = format!("{root}/realms/corp");
let path = path.split('?').next().unwrap();
match (method, path) {
("GET", "/realms/corp/.well-known/openid-configuration") => (
200,
json!({
"issuer":issuer,
"authorization_endpoint":format!("{issuer}/protocol/openid-connect/auth"),
"token_endpoint":format!("{issuer}/protocol/openid-connect/token"),
"jwks_uri":format!("{issuer}/protocol/openid-connect/certs"),
"userinfo_endpoint":format!("{issuer}/protocol/openid-connect/userinfo"),
}),
),
("GET", "/realms/corp/protocol/openid-connect/certs") => (
200,
json!({"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":MODULUS,"e":"AQAB"}]}),
),
("POST", "/realms/corp/protocol/openid-connect/token") => {
s.token_requests += 1;
let expected = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{CLIENT_ID}:{CLIENT_SECRET}"))
);
if header(head, "authorization") != Some(expected.as_str()) {
return (401, json!({"error":"invalid_client"}));
}
let form: HashMap<_, _> = reqwest::Url::parse(&format!("http://x/?{body}"))
.unwrap()
.query_pairs()
.into_owned()
.collect();
let Some(grant) = form.get("code").and_then(|c| s.codes.remove(c)) else {
return (400, json!({"error":"invalid_grant"}));
};
let verifier = form.get("code_verifier").cloned().unwrap_or_default();
if form.get("grant_type").map(String::as_str) != Some("authorization_code")
|| URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) != grant.challenge
|| !form
.get("redirect_uri")
.is_some_and(|u| u.ends_with("/api/oidc/callback"))
{
return (400, json!({"error":"invalid_grant"}));
}
let scenario = grant.scenario;
if scenario.token_status != 200 {
return (scenario.token_status, json!({"error":"server_error"}));
}
let now = chrono::Utc::now().timestamp();
let mut claims = scenario.claims.clone();
claims["iss"] = json!(scenario.issuer.unwrap_or(issuer));
claims["aud"] = scenario.audience.unwrap_or(json!(CLIENT_ID));
claims["iat"] = json!(now);
claims["exp"] = json!(now + scenario.expires_in);
claims["nonce"] = json!(scenario.nonce.unwrap_or(grant.nonce));
let access = format!("access-{}", uuid::Uuid::new_v4().simple());
if let Some(info) = scenario.userinfo {
s.tokens.insert(access.clone(), info);
}
(
200,
json!({"id_token":sign(&claims, scenario.corrupt_signature),"access_token":access,"token_type":"Bearer"}),
)
}
("GET", "/realms/corp/protocol/openid-connect/userinfo") => {
s.userinfo_requests += 1;
let token = header(head, "authorization")
.and_then(|h| h.strip_prefix("Bearer "))
.unwrap_or_default();
match s.tokens.get(token) {
Some(info) => (200, info.clone()),
None => (401, json!({"error":"invalid_token"})),
}
}
_ => (404, json!({})),
}
}
+129 -32
View File
@@ -1,7 +1,11 @@
#![allow(dead_code)]
use chrono::{Duration, Utc};
use cot::db::Model;
use cot::db::query;
use meguri::{
access::{AuthConfig, Config, WorkspaceConfig},
admin::{self, NewUser},
domain::*,
identity,
models::UserRow,
service,
store::{Store, WorkspaceRecord},
@@ -14,54 +18,83 @@ pub struct Fixture {
pub team: WorkspaceRecord,
pub schedule: Uuid,
pub start: chrono::DateTime<Utc>,
pub config: Config,
}
impl Fixture {
pub async fn new() -> Self {
pub fn config_with(workspaces: Vec<WorkspaceConfig>) -> Config {
Config {
auth: AuthConfig {
password_login: true,
bootstrap_admin: None,
},
oidc: None,
instance_admin_groups: vec![],
workspaces,
}
}
pub fn unique_workspace(name: &str) -> WorkspaceConfig {
let key = format!("t-{}", &Uuid::new_v4().simple().to_string()[..12]);
WorkspaceConfig {
admin_groups: vec![format!("{key}-leads")],
member_groups: vec![format!("{key}-staff")],
name: name.into(),
key,
}
}
pub async fn connect() -> Store {
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let db = Store::connect(&url).await.unwrap();
let mut people = vec![];
for name in ["Alice", "Bob", "Carol"] {
let user = User {
language: None,
id: Uuid::new_v4(),
Store::connect(&url).await.unwrap()
}
/// A password account whose groups are applied through the real access path.
pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String]) -> User {
let created = admin::create_password_user(
db,
config,
None,
NewUser {
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
country: "CY".into(),
timezone: None,
};
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: "test-only-unusable-password".into(),
created_at: Utc::now(),
}
.insert(&db.db)
password: "test-only-password".into(),
email: None,
groups: groups.to_vec(),
},
)
.await
.unwrap();
people.push(user);
serde_json::from_value(created["user"].clone()).unwrap()
}
impl Fixture {
pub async fn new() -> Self {
let db = connect().await;
let workspace = unique_workspace("Test team");
let config = config_with(vec![workspace.clone()]);
let ensured = identity::ensure_workspace(&db, &workspace).await.unwrap();
let mut people = vec![];
for (index, name) in ["Alice", "Bob", "Carol"].into_iter().enumerate() {
let groups = if index == 0 {
&workspace.admin_groups
} else {
&workspace.member_groups
};
people.push(account(&db, &config, name, groups).await);
}
let people: [User; 3] = people.try_into().unwrap();
let mut team = service::create_workspace(&db, people[0].id, "Test team".into())
.await
.unwrap();
for person in &people[1..] {
team = service::set_member(
let mut team = db.workspace(ensured.id).await.unwrap();
// Employees keep the narrow pre-existing grant set that the scheduling regressions assume.
team = service::set_member_permissions(
&db,
people[0].id,
team.id,
team.revision,
person.username.clone(),
Permission::member(),
)
.await
.unwrap();
}
let schedule = Uuid::new_v4();
let day = Utc::now().date_naive() + Duration::days(2);
let mut doc = team.document.clone();
@@ -86,8 +119,28 @@ impl Fixture {
team,
schedule,
start,
config,
}
}
/// A second workspace whose administrator group is granted to `admin` only.
pub async fn extra_workspace(&self, name: &str, admin: &User) -> WorkspaceRecord {
let extra = unique_workspace(name);
let mut config = self.config.clone();
config.workspaces.push(extra.clone());
let ensured = identity::ensure_workspace(&self.db, &extra).await.unwrap();
let id = admin.id.to_string();
let row = query!(UserRow,$id==id)
.get(&self.db.db)
.await
.unwrap()
.unwrap();
let mut groups = identity::stored_groups(&row).unwrap();
groups.extend(extra.admin_groups);
identity::apply_access(&self.db, &config, admin.id, Some(groups))
.await
.unwrap();
self.db.workspace(ensured.id).await.unwrap()
}
pub fn leg(&self) -> ReplacementLeg {
ReplacementLeg {
schedule_id: self.schedule,
@@ -133,3 +186,47 @@ impl Fixture {
.unwrap();
}
}
/// Demonstration seeding reads the run's generated password from the process-wide notice.
pub fn install_demo_notice() {
meguri::demo_runtime::install(meguri::demo_runtime::Notice {
isolated: false,
retention: meguri::demo_runtime::Retention::default(),
password: meguri::access::random_password(),
accounts: meguri::demo::accounts(),
});
}
/// A uniquely named database for tests whose subject is whole-instance state (configuration
/// sync disables every workspace that is not configured, so shared databases cannot be used).
pub struct IsolatedDatabase {
pub store: Store,
admin: cot::db::Database,
name: String,
}
impl IsolatedDatabase {
pub async fn new() -> Self {
let base = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let admin = cot::db::Database::new(&base).await.unwrap();
let name = format!("meguri_access_test_{}", Uuid::new_v4().simple());
admin.raw(&format!("CREATE DATABASE {name}")).await.unwrap();
let mut url = reqwest::Url::parse(&base).unwrap();
url.set_path(&format!("/{name}"));
let store = Store::connect(url.as_str()).await.unwrap();
Self { store, admin, name }
}
pub async fn url(&self) -> String {
let base = std::env::var("MEGURI_TEST_DATABASE_URL").unwrap();
let mut url = reqwest::Url::parse(&base).unwrap();
url.set_path(&format!("/{}", self.name));
url.to_string()
}
pub async fn drop(self) {
drop(self.store);
self.admin
.raw(&format!("DROP DATABASE {} WITH (FORCE)", self.name))
.await
.unwrap();
}
}