Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model

NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-07 16:06:10 +01:00
commit 38105c65c2
57 files changed
+6890 -431

No files matched your search

+127 -25
View File
@@ -50,6 +50,9 @@ async fn try_sync(
async fn sync(db: &Store, config: &Config, now: DateTime<Utc>) -> SyncReport {
try_sync(db, config, now).await.unwrap()
}
fn person_mut(document: &mut WorkspaceDocument, id: Uuid) -> &mut Person {
document.people.iter_mut().find(|p| p.id == id).unwrap()
}
fn set(permissions: Vec<Permission>) -> BTreeSet<Permission> {
permissions.into_iter().collect()
}
@@ -127,12 +130,13 @@ async fn configuration_sync_creates_adopts_renames_disables_and_reenables_withou
assert_eq!(workspace_count(db, employee.id).await, 1);
// Renaming in configuration renames the workspace through an ordinary revision.
let before_rename = db.workspace(legacy.id).await.unwrap().revision;
config.workspaces[0].name = "Legacy renamed".into();
let report = sync(db, &config, at(10, 2)).await;
assert_eq!(report.renamed, ["legacy-team"]);
let renamed = db.workspace(legacy.id).await.unwrap();
assert_eq!(renamed.document.name, "Legacy renamed");
assert_eq!(renamed.revision, adopted.revision + 1);
assert_eq!(renamed.revision, before_rename + 1);
assert!(
db.audit(legacy.id)
.await
@@ -198,16 +202,19 @@ async fn configuration_sync_creates_adopts_renames_disables_and_reenables_withou
);
// Putting it back re-enables the same workspace with its members and a pending publication.
let revision = query!(WorkspaceRow,$id==id.clone())
.get(&db.db)
.await
.unwrap()
.unwrap()
.revision;
config.workspaces.push(kept);
let report = sync(db, &config, at(10, 4)).await;
assert_eq!(report.enabled, ["payments"]);
assert_eq!(report.created.len(), 0);
assert_eq!(db.workspace(payments.id).await.unwrap().revision, revision);
assert_eq!(workspace_count(db, employee.id).await, 1);
assert_eq!(
db.sync_status(payments.id).await.unwrap()["state"],
"pending"
);
assert_eq!(db.sync_status(payments.id).await.unwrap()["state"], "draft");
// Safety rails.
let empty = config_with(vec![]);
@@ -342,6 +349,7 @@ async fn groups_decide_roles_exactly_and_instance_administrators_see_everything(
.iter()
.any(|m| m.user.id == root.id)
);
let beta = db.workspace(beta.id).await.unwrap();
let mut doc = beta.document.clone();
doc.horizon_days = 30;
let helped = service::update_document(db, root.id, beta.id, beta.revision, doc)
@@ -507,7 +515,9 @@ async fn employee_permissions_belong_to_the_workspace_and_apply_to_everyone_at_o
.unwrap()
.contains(&Permission::ForceOverrides)
);
let applied = service::request_replacement(&f.db, bob, team, force(updated.revision))
// Adding the newcomer also added their person to the workspace: take the current revision.
let current = f.db.workspace(team).await.unwrap();
let applied = service::request_replacement(&f.db, bob, team, force(current.revision))
.await
.unwrap();
assert_eq!(
@@ -1231,7 +1241,7 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
sign_in(
db,
&config,
profile("sd", "dana", Some("DANA@corp.test"), &["alpha-staff"]),
profile("sd", "dana", Some("dana@corp.test"), &["alpha-staff"]),
)
.await;
assert_eq!(
@@ -1239,6 +1249,29 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
linked.revision
);
// The linked person is an account person: with the next ordinary commit the account profile
// (provider display name, default country) becomes authoritative, while the lead's contact
// data was kept until then. The email, ID and alias stay.
let committed = service::update_document(
db,
lead.id,
alpha.id,
linked.revision,
linked.document.clone(),
)
.await
.unwrap();
let person = committed
.document
.people
.iter()
.find(|p| p.id == dana.id)
.unwrap();
assert_eq!(person.display_name, dana.display_name);
assert_eq!(person.country.as_deref(), Some("CY"));
assert_eq!(person.email.as_deref(), Some("dana@corp.test"));
assert_eq!(person.aliases, vec![contact]);
// A colleague nobody planned still becomes a person with an email, ready for providers.
let (erin, _) = sign_in(
db,
@@ -1254,18 +1287,19 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
.find(|p| p.id == erin.id)
.unwrap();
assert_eq!(person.email.as_deref(), Some("erin@corp.test"));
// No email, no person entry, still a member.
// No email: still a member, with a person of their own and nothing to link.
let (frank, _) = sign_in(db, &config, profile("sf", "frank", None, &["alpha-staff"])).await;
assert!(db.permissions(alpha.id, frank.id).await.is_ok());
assert!(
!db.workspace(alpha.id)
.await
.unwrap()
.document
.people
.iter()
.any(|p| p.id == frank.id)
);
let frank_person = db
.workspace(alpha.id)
.await
.unwrap()
.document
.people
.into_iter()
.find(|p| p.id == frank.id)
.unwrap();
assert!(frank_person.email.is_none() && frank_person.user_id == Some(frank.id));
iso.drop().await;
}
@@ -1313,15 +1347,15 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
),
("horizon", &|d| d.horizon_days = 30),
("person name", &|d| {
d.people[0].display_name = "Someone else".into()
person_mut(d, contact).display_name = "Someone else".into()
}),
("person email", &|d| {
d.people[0].email = Some("other@corp.test".into())
person_mut(d, contact).email = Some("other@corp.test".into())
}),
("person country", &|d| {
d.people[0].country = Some("CY".into())
person_mut(d, contact).country = Some("CY".into())
}),
("person removal", &|d| d.people.clear()),
("person removal", &|d| d.people.retain(|p| p.id != contact)),
] {
assert!(
matches!(try_edit(change).await, Err(Error::Forbidden)),
@@ -1339,24 +1373,33 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
country: None,
aliases: vec![],
});
d.people[0].email = Some(" DANA@corp.test ".into());
person_mut(d, contact).email = Some(" DANA@corp.test ".into());
})
.await
.unwrap();
assert_eq!(planned.document.schedules[0].name, "Weekly rotation");
assert_eq!(planned.document.name, record.document.name);
assert_eq!(planned.document.people.len(), 2);
assert!(
planned
.document
.people
.iter()
.any(|p| p.id != contact && p.user_id.is_none())
);
// The workspace administrator still changes both.
let mut doc = planned.document.clone();
doc.name = "Administrators rename".into();
doc.horizon_days = 45;
doc.people[0].display_name = "Dana Renamed".into();
person_mut(&mut doc, contact).display_name = "Dana Renamed".into();
let changed = service::update_document(&f.db, lead, team, planned.revision, doc)
.await
.unwrap();
assert_eq!(changed.document.name, "Administrators rename");
assert_eq!(changed.document.horizon_days, 45);
assert_eq!(changed.document.people[0].display_name, "Dana Renamed");
assert_eq!(
changed.document.person(contact).unwrap().display_name,
"Dana Renamed"
);
}
#[tokio::test]
@@ -1437,3 +1480,62 @@ async fn the_instance_event_log_is_searchable_newest_first_and_failed_logins_are
);
iso.drop().await;
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
async fn a_departed_member_keeps_a_valid_person_and_is_a_member_again_after_rejoining() {
let f = support::Fixture::new().await;
let (lead, bob) = (f.people[0].id, f.people[1].id);
let team = f.team.id;
let workspace = f.config.workspaces[0].clone();
// The person of every member exists in the workspace once it is edited.
let record =
service::update_document(&f.db, lead, team, f.team.revision, f.team.document.clone())
.await
.unwrap();
assert!(
record
.document
.people
.iter()
.any(|p| p.id == bob && p.user_id == Some(bob))
);
// Access follows groups: bob leaves; his person (and every schedule reference) stays valid.
identity::apply_access(&f.db, &f.config, bob, Some(vec![]))
.await
.unwrap();
let after =
service::update_document(&f.db, lead, team, record.revision, record.document.clone())
.await
.unwrap();
let person = after.document.people.iter().find(|p| p.id == bob).unwrap();
assert_eq!(
person.user_id,
Some(bob),
"the account link survives the departure"
);
assert!(
f.db.member_ids(team)
.await
.unwrap()
.iter()
.all(|id| *id != bob)
);
assert!(f.db.permissions(team, bob).await.is_err());
// Returning only restores access; the person is already linked.
identity::apply_access(&f.db, &f.config, bob, Some(workspace.member_groups.clone()))
.await
.unwrap();
let current = f.db.workspace(team).await.unwrap();
let back = service::update_document(
&f.db,
lead,
team,
current.revision,
current.document.clone(),
)
.await
.unwrap();
let person = back.document.people.iter().find(|p| p.id == bob).unwrap();
assert_eq!(person.user_id, Some(bob));
}