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

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

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

No files matched your search

+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