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:
1 parent
7afdc4be97
commit
548494f12e
11 files changed
+413
-18
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
Reference in new issue
Block a user