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
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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