Compare commits

...
2 Commits
Author SHA1 Message Date
Aleksandr BogomiakovandClaude 6a55988671 Bump version to 0.2.0
Checks / test (push) Failing after 3m24s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:22 +01:00
Aleksandr BogomiakovandClaude 548494f12e 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>
2026-10-07 15:06:12 +01:00
14 changed files with 417 additions and 22 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1794,7 +1794,7 @@ dependencies = [
[[package]]
name = "meguri"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"argon2",
"base64 0.23.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "meguri"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.97"
description = "Provider-independent on-call scheduling and configuration control plane"
+2 -2
View File
@@ -2,6 +2,6 @@ apiVersion: v2
name: meguri
description: On-call schedules, replacements and escalation policies as a source of truth
type: application
version: 0.1.0
appVersion: "0.1.0"
version: 0.2.0
appVersion: "0.2.0"
kubeVersion: ">=1.29.0-0"
+58
View File
@@ -329,3 +329,61 @@ pub async fn delete_workspace(
)
.await
}
const EVENT_LIMIT: usize = 300;
/// The newest instance events, newest first, optionally filtered by a case-insensitive text that
/// is matched against the action, the actor and the details. A support reference from a failed
/// sign-in (the `ref` shown on the login screen) is found this way.
pub async fn events(db: &Store, actor: Uuid, search: &str) -> Result<Value> {
require_instance_admin(db, actor).await?;
let needle = search.trim().to_lowercase();
ensure(
needle.len() <= 200,
crate::i18n::text("err_the_search_text_is_too_long_7c3d58"),
)?;
let mut rows = InstanceAuditRow::objects().all(&db.db).await?;
rows.sort_by_key(|row| std::cmp::Reverse(row.id.unwrap()));
let mut names: BTreeMap<String, String> = BTreeMap::new();
let mut events = vec![];
let mut more = false;
for row in rows {
let actor_name = match &row.actor_id {
None => None,
Some(id) => {
if !names.contains_key(id) {
let key = id.clone();
let name = query!(UserRow,$id==key)
.get(&db.db)
.await?
.map_or_else(|| "unknown".to_owned(), |u| u.username);
names.insert(id.clone(), name);
}
names.get(id).cloned()
}
};
if !needle.is_empty()
&& ![
row.action.as_str(),
actor_name.as_deref().unwrap_or(""),
row.details.as_str(),
]
.iter()
.any(|text| text.to_lowercase().contains(&needle))
{
continue;
}
if events.len() == EVENT_LIMIT {
more = true;
break;
}
events.push(json!({
"id":row.id.unwrap(),
"action":row.action,
"actor":actor_name,
"details":serde_json::from_str::<Value>(&row.details).unwrap_or(Value::Null),
"created_at":row.created_at,
}));
}
Ok(json!({"events":events,"more":more}))
}
+8 -1
View File
@@ -680,7 +680,14 @@ pub async fn sso_sign_in(db: &Store, config: &Config, profile: SsoProfile) -> Re
if resolve_access(config, &profile.groups, false).is_empty() {
return Ok(SsoOutcome::NoAccess);
}
create_sso_user(db, &profile).await?
let created = create_sso_user(db, &profile).await?;
db.instance_event(
None,
"user.created",
json!({"user_id":created,"sso":true,"subject":profile.subject,"email":profile.email,"groups":profile.groups}),
)
.await?;
created
}
};
let id = store::uuid(&user_id)?;
+8
View File
@@ -282,6 +282,14 @@ impl Store {
&[&actor, &action, &details],
)
.await?;
if action == "login.failed" {
// Anonymous callers can create these rows, so only the newest few thousand are kept.
self.db
.raw(
"DELETE FROM meguri__instance_audit WHERE action='login.failed' AND id<=COALESCE((SELECT id FROM meguri__instance_audit WHERE action='login.failed' ORDER BY id DESC OFFSET 2000 LIMIT 1),0)",
)
.await?;
}
Ok(())
}
pub async fn audit(&self, workspace: Uuid) -> Result<Vec<Value>> {
+68 -15
View File
@@ -53,6 +53,7 @@ impl App for MeguriApp {
"/api/oidc/login",
"/api/oidc/callback",
"/api/admin/overview",
"/api/admin/events",
"/api/admin/users",
"/api/admin/users/{user}/groups",
"/api/admin/users/{user}/password",
@@ -420,7 +421,7 @@ async fn dispatch(request: Request) -> Result<Response> {
}
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
if parts.len() >= 3 && parts[1] == "admin" {
return admin_route(&db, config, actor, &parts, &method, body).await;
return admin_route(&db, config, actor, &parts, &method, &query_string, body).await;
}
if parts.len() < 3 || parts[1] != "workspaces" {
return Err(Error::NotFound);
@@ -749,9 +750,24 @@ fn redirect(location: &str) -> Result<Response> {
.map_err(|e| Error::Internal(e.to_string()))
}
fn login_failure(code: &str, detail: impl std::fmt::Display) -> Result<Response> {
/// Every refused SSO sign-in gets a support reference that is shown to the person, logged, and
/// stored as an instance event so an administrator can look it up on the instance page.
async fn login_failure(
db: &Store,
code: &str,
detail: impl std::fmt::Display,
context: Value,
) -> Result<Response> {
let reference = Uuid::new_v4().simple().to_string()[..8].to_owned();
let detail = detail.to_string();
tracing::warn!(%reference, %code, %detail, "SSO sign-in was not completed");
let mut details = json!({"reference":reference,"code":code,"detail":detail});
if let (Some(extra), Some(target)) = (context.as_object(), details.as_object_mut()) {
target.extend(extra.clone());
}
if let Err(error) = db.instance_event(None, "login.failed", details).await {
tracing::error!(%error, "could not record the failed sign-in");
}
redirect(&format!("/?login_error={code}&ref={reference}"))?
.with_header("Set-Cookie", flow_cookie("", 0))
.into_response()
@@ -767,7 +783,7 @@ async fn oidc_login(db: &Store) -> Result<Response> {
.with_header("Set-Cookie", flow_cookie(&state, 600))
.into_response()
.map_err(|e| Error::Internal(e.to_string())),
Err(error) => login_failure("unavailable", error),
Err(error) => login_failure(db, "unavailable", error, json!({})).await,
}
}
@@ -785,10 +801,16 @@ async fn oidc_callback(
.map_err(|_| Error::NotFound)?;
let params: std::collections::BTreeMap<_, _> = url.query_pairs().into_owned().collect();
if let Some(error) = params.get("error") {
return login_failure("failed", format!("provider returned {error}"));
return login_failure(
db,
"failed",
format!("provider returned {error}"),
json!({}),
)
.await;
}
let (Some(state), Some(code)) = (params.get("state"), params.get("code")) else {
return login_failure("failed", "callback without state or code");
return login_failure(db, "failed", "callback without state or code", json!({})).await;
};
let cookie_state = request
.headers()
@@ -801,8 +823,12 @@ async fn oidc_callback(
.map(str::to_owned);
let profile = match oidc::complete(db, cookie_state.as_deref(), state, code).await {
Ok(profile) => profile,
Err(oidc::Failure::Unavailable(detail)) => return login_failure("unavailable", detail),
Err(oidc::Failure::Invalid(detail)) => return login_failure("failed", detail),
Err(oidc::Failure::Unavailable(detail)) => {
return login_failure(db, "unavailable", detail, json!({})).await;
}
Err(oidc::Failure::Invalid(detail)) => {
return login_failure(db, "failed", detail, json!({})).await;
}
};
match identity::sso_sign_in(db, config, profile.clone()).await {
Ok(identity::SsoOutcome::SignedIn { token, .. }) => redirect("/")?
@@ -810,14 +836,30 @@ async fn oidc_callback(
.with_header("Set-Cookie", flow_cookie("", 0))
.into_response()
.map_err(|e| Error::Internal(e.to_string())),
Ok(identity::SsoOutcome::NoAccess) => login_failure(
"no_access",
format!(
"no workspace or instance role matches the groups {:?} of subject {}",
profile.groups, profile.subject
),
),
Err(error) => login_failure("failed", error),
Ok(identity::SsoOutcome::NoAccess) => {
login_failure(
db,
"no_access",
"no workspace or instance role matches the groups of this account",
json!({
"subject":profile.subject,
"username":profile.username_hint,
"email":profile.email,
"groups":profile.groups,
"issuer":profile.issuer,
}),
)
.await
}
Err(error) => {
login_failure(
db,
"failed",
error,
json!({"subject":profile.subject,"email":profile.email}),
)
.await
}
}
}
@@ -827,6 +869,7 @@ async fn admin_route(
actor: Uuid,
parts: &[&str],
method: &Method,
query_string: &str,
body: Value,
) -> Result<Response> {
use crate::admin;
@@ -834,6 +877,16 @@ async fn admin_route(
(["api", "admin", "overview"], &Method::GET) => {
response(admin::overview(db, config, actor).await?)
}
(["api", "admin", "events"], &Method::GET) => {
let url = reqwest::Url::parse(&format!("http://localhost/?{query_string}"))
.map_err(|_| Error::NotFound)?;
let search = url
.query_pairs()
.find(|(key, _)| key == "search")
.map(|(_, value)| value.into_owned())
.unwrap_or_default();
response(admin::events(db, actor, &search).await?)
}
(["api", "admin", "users"], &Method::POST) => {
response(admin::create_user(db, config, actor, decode(body)?).await?)
}
+7
View File
@@ -213,3 +213,10 @@ a.button{display:inline-block;text-decoration:none;border:1px solid #dbe3de;bord
@media(max-width:700px){.sidebar nav .instance-link{margin:0;border:0;border-radius:6px;padding:6px}.sidebar nav .instance-link>span:not(.nav-icon){position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}}
.admin-user .badge,.admin-workspace .badge{text-transform:none}.admin-user strong{display:inline}
@media(max-width:700px){.sidebar nav .instance-link{padding:6px 2px}.sidebar nav .instance-link .nav-icon{width:16px}}
.event-search{display:flex;gap:8px;margin:12px 0}.event-search input{flex:1;min-width:0}.event-search button{white-space:nowrap}.event-search .primary{width:auto;margin:0;padding:9px 13px}
.event-list{display:flex;flex-direction:column}.event-row{padding:12px 0;border-top:1px solid var(--line);min-width:0}.event-row:first-child{border-top:0}.event-row.failed{border-left:3px solid #d99a82;padding-left:12px;background:#fff9f6}
.event-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.event-actor{font-weight:600}.event-time{color:var(--muted);margin-left:auto}
.event-action{font:12px/1.4 Consolas,monospace;background:#eef3f0;color:#3d5a4c;padding:2px 7px;border-radius:5px;overflow-wrap:anywhere}.event-action.failed{background:#fbe3da;color:#a04e34}
.event-details{display:flex;flex-wrap:wrap;gap:4px 18px;margin:6px 0 0}.event-details>div{min-width:0;max-width:100%}.event-details dt{display:inline;color:var(--muted);font-size:11px}.event-details dt::after{content:": "}.event-details dd{display:inline;margin:0;overflow-wrap:anywhere;word-break:break-word;font-size:12px}
.chips{display:inline-flex;flex-wrap:wrap;gap:4px}.chip{font:11px/1.4 Consolas,monospace;background:#eef3f0;padding:1px 6px;border-radius:10px;overflow-wrap:anywhere}
@media(max-width:700px){.event-search{flex-wrap:wrap}.event-search input{flex-basis:100%}.event-time{margin-left:0;flex-basis:100%}}
+8 -2
View File
@@ -18,7 +18,7 @@ document.addEventListener('alpine:init', () => {
}));
Alpine.data('meguri', () => ({
...generationUi(),
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null,
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null, events:[], eventsMore:false, eventSearch:'', eventsLoading:false,
loginForm: { username: '', password: '' },
directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] },
view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false,
@@ -72,7 +72,12 @@ document.addEventListener('alpine:init', () => {
get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); },
get employeeGrantOptions(){return this.grantOptions.filter(g=>!['manage_members','manage_integration'].includes(g.id));},
canEditUserGroups(u){return !u.sso && !u.bootstrap && u.id!==this.user?.id;},
async loadAdmin(){this.admin=await this.api('/api/admin/overview');},
async loadAdmin(){this.admin=await this.api('/api/admin/overview');await this.loadEvents();},
async loadEvents(){const query=this.eventSearch.trim(),sequence=++this.eventSequence;this.eventsLoading=true;try{const result=await this.api('/api/admin/events'+(query?'?search='+encodeURIComponent(query):''));if(sequence===this.eventSequence){this.events=result.events;this.eventsMore=!!result.more;}}finally{if(sequence===this.eventSequence)this.eventsLoading=false;}},
eventSequence:0,
isFailedLogin(event){return event.action==='login.failed';},
eventLabel(key){return MEGURI_MESSAGES['access_event_key_'+key]?tr('access_event_key_'+key):key;},
eventDetails(event){const details=event.details;if(!details || typeof details!=='object')return [];const order=['reference','code','detail','username','email','subject','issuer','groups'];const keys=[...order.filter(k=>k in details),...Object.keys(details).filter(k=>!order.includes(k))];return keys.map(key=>{const value=details[key];return Array.isArray(value)?{key,chips:value.map(String),empty:!value.length}:{key,value:typeof value==='object' && value!==null?JSON.stringify(value):String(value??'')};});},
useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';},
async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}},
get setupView() { return ['configuration','settings'].includes(this.view); },
@@ -124,6 +129,7 @@ document.addEventListener('alpine:init', () => {
this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}});
this.$watch('view',v=>{if(v==='instance'){if(this.user?.instance_admin)this.run(()=>this.loadAdmin());}if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();});
this.consumeLoginError();
{const query=new URLSearchParams(location.search);this.eventSearch=(query.get('ref')||query.get('search')||'').slice(0,200);}
const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation());
await this.run(async () => { const session = await this.api('/api/session'); this.user = session.user; this.csrf = session.csrf || ''; this.auth = session.auth || {oidc:false,password:true}; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) { if(this.view==='instance' && !this.user.instance_admin)this.view='calendar'; await this.loadWorkspaces(); if(this.view==='instance')await this.loadAdmin(); } });
if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft();
+23
View File
@@ -161,6 +161,29 @@
</div>
</template>
</div>
<div class="section-line"><h2><span x-text="t('access_events_title')"></span></h2></div>
<article class="panel rule-card spaced" id="instance-events">
<p class="muted"><span x-text="t('access_events_note')"></span></p>
<form class="event-search" @submit.prevent="run(()=>loadEvents())">
<input type="search" x-model="eventSearch" maxlength="200" :aria-label="t('access_events_search_label')" :placeholder="t('access_events_search_placeholder')">
<button class="primary" :disabled="busy"><span x-text="t('access_events_search')"></span></button>
<button type="button" :disabled="busy" @click="run(()=>loadEvents())"><span x-text="t('access_events_refresh')"></span></button>
</form>
<p class="muted padded" data-events-empty x-show="!events.length && !eventsLoading"><span x-text="t('access_events_empty')"></span></p>
<div class="event-list">
<template x-for="e in events" :key="e.id">
<div class="event-row" :class="{failed:isFailedLogin(e)}" :data-event-action="e.action">
<div class="event-head"><code class="event-action" :class="{failed:isFailedLogin(e)}" x-text="e.action"></code><span class="event-actor" x-text="e.actor || t('access_event_system')"></span><small class="event-time" x-text="formatTime(e.created_at)"></small></div>
<dl class="event-details">
<template x-for="d in eventDetails(e)" :key="d.key">
<div><dt x-text="eventLabel(d.key)"></dt><dd><template x-if="d.chips"><span class="chips"><template x-for="chip in d.chips" :key="chip"><code class="chip" x-text="chip"></code></template><small class="muted" x-show="d.empty" x-text="t('access_user_no_groups')"></small></span></template><template x-if="!d.chips"><span x-text="d.value"></span></template></dd></div>
</template>
</dl>
</div>
</template>
</div>
<p class="info-note" data-events-more x-show="eventsMore"><span x-text="t('access_events_more')"></span></p>
</article>
<div class="section-line"><h2><span x-text="t('access_users_title')"></span></h2>
<button class="primary" @click="open('admin-user')"><span x-text="t('access_add_user')"></span></button>
</div>
+95
View File
@@ -6303,5 +6303,100 @@
"en": "This account signs in with SSO and has no password",
"ru": "Эта учётная запись входит через SSO и не имеет пароля",
"fr": "Ce compte se connecte par SSO et n’a pas de mot de passe"
},
"err_the_search_text_is_too_long_7c3d58": {
"en": "The search text is too long",
"ru": "Текст поиска слишком длинный",
"fr": "Le texte de recherche est trop long"
},
"access_events_title": {
"en": "Event log",
"ru": "Журнал событий",
"fr": "Journal des événements"
},
"access_events_search_label": {
"en": "Search events",
"ru": "Поиск событий",
"fr": "Rechercher des événements"
},
"access_events_search_placeholder": {
"en": "Search events, users or a sign-in reference",
"ru": "Событие, пользователь или код входа",
"fr": "Événement, utilisateur ou référence de connexion"
},
"access_events_search": {
"en": "Search",
"ru": "Найти",
"fr": "Rechercher"
},
"access_events_refresh": {
"en": "Refresh",
"ru": "Обновить",
"fr": "Actualiser"
},
"access_events_empty": {
"en": "No events match",
"ru": "Подходящих событий нет",
"fr": "Aucun événement correspondant"
},
"access_events_more": {
"en": "Only the newest 300 events are shown. Narrow the search to find older ones.",
"ru": "Показаны только 300 последних событий. Уточните поиск, чтобы найти более старые.",
"fr": "Seuls les 300 événements les plus récents sont affichés. Affinez la recherche pour trouver les plus anciens."
},
"access_events_note": {
"en": "Sign-in failures, account changes and workspace lifecycle events of the whole instance, newest first.",
"ru": "Ошибки входа, изменения учётных записей и жизненный цикл пространств всего инстанса, сначала новые.",
"fr": "Échecs de connexion, changements de comptes et cycle de vie des espaces de toute l’instance, du plus récent au plus ancien."
},
"access_event_system": {
"en": "System",
"ru": "Система",
"fr": "Système"
},
"access_event_failed_flag": {
"en": "Failed sign-in",
"ru": "Неудачный вход",
"fr": "Connexion échouée"
},
"access_event_key_reference": {
"en": "Reference",
"ru": "Код",
"fr": "Référence"
},
"access_event_key_code": {
"en": "Code",
"ru": "Тип",
"fr": "Code"
},
"access_event_key_detail": {
"en": "Reason",
"ru": "Причина",
"fr": "Motif"
},
"access_event_key_subject": {
"en": "Subject",
"ru": "Субъект",
"fr": "Sujet"
},
"access_event_key_username": {
"en": "Username",
"ru": "Имя пользователя",
"fr": "Identifiant"
},
"access_event_key_email": {
"en": "Email",
"ru": "Email",
"fr": "E-mail"
},
"access_event_key_groups": {
"en": "Groups from provider",
"ru": "Группы от провайдера",
"fr": "Groupes du fournisseur"
},
"access_event_key_issuer": {
"en": "Issuer",
"ru": "Провайдер",
"fr": "Émetteur"
}
}
+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"}),