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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user