diff --git a/src/admin.rs b/src/admin.rs index 78c0894..0fe0da3 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -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 { + 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 = 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::(&row.details).unwrap_or(Value::Null), + "created_at":row.created_at, + })); + } + Ok(json!({"events":events,"more":more})) +} diff --git a/src/identity.rs b/src/identity.rs index 28a9ea3..180d9e0 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -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)?; diff --git a/src/store.rs b/src/store.rs index 955f24d..46f1055 100644 --- a/src/store.rs +++ b/src/store.rs @@ -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> { diff --git a/src/web.rs b/src/web.rs index a387d10..1058799 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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 { } 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 { .map_err(|e| Error::Internal(e.to_string())) } -fn login_failure(code: &str, detail: impl std::fmt::Display) -> Result { +/// 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 { 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 { .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 { 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?) } diff --git a/static/app.css b/static/app.css index 385a45a..ed06204 100644 --- a/static/app.css +++ b/static/app.css @@ -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%}} diff --git a/static/app.js b/static/app.js index b435c29..b863e80 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); diff --git a/static/index.html b/static/index.html index 60abe5f..e7b8b86 100644 --- a/static/index.html +++ b/static/index.html @@ -161,6 +161,29 @@ +

+
+

+ +

+
+ +
+

+

diff --git a/static/locales/messages.json b/static/locales/messages.json index 55ffb14..e31e59c 100644 --- a/static/locales/messages.json +++ b/static/locales/messages.json @@ -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" } } diff --git a/tests/access.rs b/tests/access.rs index f3a650c..7194afd 100644 --- a/tests/access.rs +++ b/tests/access.rs @@ -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; +} diff --git a/tests/browser-access.cjs b/tests/browser-access.cjs index aeaea21..6fdb4f2 100644 --- a/tests/browser-access.cjs +++ b/tests/browser-access.cjs @@ -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(); diff --git a/tests/oidc.rs b/tests/oidc.rs index 2c0b5fd..b596b85 100644 --- a/tests/oidc.rs +++ b/tests/oidc.rs @@ -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"}),