Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log

Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-07 15:06:12 +01:00
1 parent 7afdc4be97
commit 548494f12e
11 files changed
+413 -18

No files matched your search

+80
View File
@@ -15,6 +15,7 @@ use meguri::{
service,
store::Store,
};
use serde_json::json;
use std::collections::BTreeSet;
use support::{IsolatedDatabase, account, config_with};
use uuid::Uuid;
@@ -1357,3 +1358,82 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
assert_eq!(changed.document.horizon_days, 45);
assert_eq!(changed.document.people[0].display_name, "Dana Renamed");
}
#[tokio::test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"]
async fn the_instance_event_log_is_searchable_newest_first_and_failed_logins_are_capped() {
let iso = IsolatedDatabase::new().await;
let db = &iso.store;
let mut config = config_with(vec![ws("alpha", "Alpha")]);
config.instance_admin_groups = vec!["root".into()];
sync(db, &config, at(9, 0)).await;
let root = account(db, &config, "Root", &["root".into()]).await;
let plain = account(db, &config, "Plain", &["alpha-staff".into()]).await;
assert!(matches!(
admin::events(db, plain.id, "").await,
Err(Error::Forbidden)
));
db.instance_event(
None,
"login.failed",
json!({"reference":"deadbeef","code":"no_access","groups":["/unknown"]}),
)
.await
.unwrap();
admin::create_user(
db,
&config,
root.id,
NewUser {
username: "casey".into(),
display_name: "Casey".into(),
password: "long-enough-password".into(),
email: None,
groups: vec![],
country: None,
},
)
.await
.unwrap();
let all = admin::events(db, root.id, "").await.unwrap();
let actions: Vec<_> = all["events"]
.as_array()
.unwrap()
.iter()
.map(|e| e["action"].as_str().unwrap().to_owned())
.collect();
assert_eq!(actions[0], "user.created", "newest first");
assert!(actions.contains(&"workspace.created".to_owned()));
assert_eq!(all["events"][0]["actor"], root.username);
let by_reference = admin::events(db, root.id, " DEADBEEF ").await.unwrap();
assert_eq!(by_reference["events"].as_array().unwrap().len(), 1);
assert_eq!(
by_reference["events"][0]["details"]["groups"][0],
"/unknown"
);
assert!(
admin::events(db, root.id, "no-such-text").await.unwrap()["events"]
.as_array()
.unwrap()
.is_empty()
);
// Anonymous sign-in failures cannot grow the table without bound, nor push out other events.
for n in 0..2010 {
db.instance_event(None, "login.failed", json!({"reference":format!("{n:08}")}))
.await
.unwrap();
}
let remaining = InstanceAuditRow::objects()
.all(&db.db)
.await
.unwrap()
.into_iter()
.filter(|e| e.action == "login.failed")
.count();
assert!((2000..=2002).contains(&remaining), "{remaining}");
assert!(
!events(db, "workspace.created").await.is_empty()
&& !events(db, "user.created").await.is_empty()
);
iso.drop().await;
}
+31
View File
@@ -100,6 +100,37 @@ fs.mkdirSync('test-results', { recursive: true });
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');
// The event log shows these administrator actions, can be searched, and refuses non-administrators.
const log = root.locator('#instance-events');
await log.locator('[data-event-action="user.created"]').filter({ hasText: username }).first().waitFor();
await log.locator('[data-event-action="user.password_reset"]').filter({ hasText: username }).first().waitFor();
assert.match(await log.locator('[data-event-action="user.created"]').first().innerText(), /admin/);
await log.getByPlaceholder(/sign-in reference/).fill('no-such-event-zzzz-' + Date.now());
await log.getByRole('button', { name: 'Search', exact: true }).click();
await log.locator('[data-events-empty]').waitFor();
assert.equal(await log.locator('.event-row').count(), 0);
await log.getByPlaceholder(/sign-in reference/).fill(username);
await log.getByRole('button', { name: 'Search', exact: true }).click();
await log.locator('.event-row').first().waitFor();
const matching = await log.locator('.event-row').evaluateAll(rows => rows.map(r => r.textContent));
assert(matching.length >= 3 && matching.every(text => text.includes(username)), 'Search returns only events about the user');
assert.equal(await log.locator('[data-events-empty]').isVisible(), false);
assert.equal(await log.locator('[data-events-more]').isVisible(), false);
await root.screenshot({ path: 'test-results/access-event-log.png', fullPage: true });
// A support reference in the URL prefills the search.
const byReference = await open();
await signIn(byReference, 'admin');
await byReference.goto(`${base}/?view=instance&ref=${username}`);
await byReference.locator('#instance-events [data-event-action="user.created"]').first().waitFor();
assert.equal(await byReference.locator('#instance-events input[type=search]').inputValue(), username);
const mobile = await open({ width: 390, height: 844 });
await signIn(mobile, 'admin');
await mobile.goto(`${base}/?view=instance`);
await mobile.locator('#instance-events .event-row').first().waitFor();
assert(await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Event log must not overflow on mobile');
const forbidden = await (await apiLogin('bob')).call('GET', '/api/admin/events');
assert.equal(forbidden.status, 403);
assert.equal((await (await apiLogin('alice')).call('GET', '/api/admin/events?search=x')).status, 403);
const outsider = await open();
await signIn(outsider, username, 'second-test-password-2');
await outsider.getByRole('heading', { name: 'No workspace access yet', exact: true }).waitFor();
+27
View File
@@ -316,6 +316,33 @@ workspaces:
"no_access",
);
assert_eq!(reference.len(), 8);
// The reference on the screen leads an administrator to the stored reason and the groups
// the provider actually sent; ordinary users cannot read the event log.
let found: Value = h
.client
.get(format!("{base}/api/admin/events?search={reference}"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(found["events"].as_array().unwrap().len(), 1);
let event = &found["events"][0];
assert_eq!(event["action"], "login.failed");
assert_eq!(event["details"]["reference"], reference);
assert_eq!(event["details"]["code"], "no_access");
assert_eq!(event["details"]["subject"], "u-4");
assert_eq!(event["details"]["groups"], json!(["/unknown"]));
let denied = h
.client
.get(format!("{base}/api/admin/events"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
h.refused(
&h.attempt(Scenario::new(
json!({"sub":"u-5","preferred_username":"nogroups"}),