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:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
+25
-327
@@ -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
|
||||
|
||||
Reference in new issue
Block a user