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?)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user