//! trustinWallet — access management, for its administrators only — and the //! « Retour » channel that turns usage tests into controls. use crate::AppState; use crate::auth::{self, IDENTITY_COLUMNS, IdentityRow, User}; use crate::error::{AppError, AppResult}; use crate::util; use axum::Json; use axum::extract::{Path, State}; use serde::Deserialize; use serde_json::json; fn admin_only(user: &IdentityRow) -> AppResult<()> { if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) } } pub async fn overview(State(state): State, User(user): User) -> AppResult> { admin_only(&user)?; #[derive(sqlx::FromRow)] struct Member { group_slug: String, identity_id: String, granted_at: String, } let groups: Vec<(String, String, String, bool)> = sqlx::query_as("SELECT slug, name, policy, hidden FROM groups ORDER BY position").fetch_all(&state.db).await?; let members = sqlx::query_as::<_, Member>("SELECT group_slug, identity_id, granted_at FROM memberships") .fetch_all(&state.db) .await?; let identities = sqlx::query_as::<_, IdentityRow>(&format!( "SELECT {IDENTITY_COLUMNS} FROM identities ORDER BY admin DESC, display_name COLLATE NOCASE" )) .fetch_all(&state.db) .await?; let last_seen: Vec<(String, Option)> = sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?; let came_by: Vec<(String, String)> = sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link") .fetch_all(&state.db) .await?; let invitations: Vec<(String, String, bool, String, String, Option)> = sqlx::query_as( "SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC", ) .bind(util::now()) .fetch_all(&state.db) .await?; let mut publication = json!({ "remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()), "publicKey": crate::publish::public_key(&state), }); if state.cfg.publish.is_some() { publication["status"] = crate::publish::status(&state).await?; } Ok(Json(json!({ "publication": publication, "groups": groups.iter().map(|(slug, name, policy, hidden)| json!({ "slug": slug, "name": name, "policy": policy, "hidden": hidden, "members": members.iter().filter(|m| &m.group_slug == slug) .map(|m| json!({ "identityId": m.identity_id, "grantedAt": m.granted_at })).collect::>(), })).collect::>(), "identities": identities.iter().map(|i| json!({ "id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin, "lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()), "teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()), })).collect::>(), "teamLinks": crate::team::list(&state).await?, "invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({ "id": hash, "label": label, "admin": admin, "groups": serde_json::from_str::>(groups).unwrap_or_default(), "expiresAt": expires, "identityId": identity, })).collect::>(), }))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct GrantBody { identity_id: String, } pub async fn grant( State(state): State, User(user): User, Path(slug): Path, Json(b): Json, ) -> AppResult> { admin_only(&user)?; auth::load_identity(&state, &b.identity_id).await?; let done = sqlx::query( "INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at) SELECT slug, ?, ?, ? FROM groups WHERE slug = ?", ) .bind(&b.identity_id) .bind(&user.id) .bind(util::now()) .bind(&slug) .execute(&state.db) .await?; tracing::info!(group = %slug, identity = %b.identity_id, by = %user.id, "access granted"); Ok(Json(json!({ "granted": done.rows_affected() == 1 }))) } pub async fn revoke( State(state): State, User(user): User, Path((slug, identity)): Path<(String, String)>, ) -> AppResult> { admin_only(&user)?; let done = sqlx::query("DELETE FROM memberships WHERE group_slug = ? AND identity_id = ?") .bind(&slug) .bind(&identity) .execute(&state.db) .await?; tracing::info!(group = %slug, identity = %identity, by = %user.id, "access revoked"); Ok(Json(json!({ "revoked": done.rows_affected() == 1 }))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct InviteBody { label: String, #[serde(default)] admin: bool, #[serde(default)] groups: Vec, identity_id: Option, } /// The link is returned once and never stored in clear. pub async fn invite( State(state): State, User(user): User, Json(b): Json, ) -> AppResult> { admin_only(&user)?; let label = b.label.trim(); if label.is_empty() || label.chars().count() > 60 || label.chars().any(char::is_control) || ld_engine::edit::has_hidden_controls(label) { return Err(AppError::Invalid("label")); } // A new link for an existing person is for invitation identities only: a link // to a Ğ1 identity would let an administrator open a session in its name. if let Some(id) = &b.identity_id && auth::load_identity(&state, id).await?.kind != "invite" { return Err(AppError::Invalid("identity_kind")); } let token = auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?; Ok(Json(json!({ "link": auth::invitation_link(&state, &token) }))) } pub async fn cancel_invitation( State(state): State, User(user): User, Path(hash): Path, ) -> AppResult> { admin_only(&user)?; sqlx::query("UPDATE invitations SET expires_at = ? WHERE token_hash = ? AND used_at IS NULL") .bind(util::now()) .bind(&hash) .execute(&state.db) .await?; Ok(Json(json!({ "ok": true }))) } /// « Publier maintenant » : a key just added, a remote just created — no need /// to wait for the backoff. pub async fn publish_now(State(state): State, User(user): User) -> AppResult> { admin_only(&user)?; if state.cfg.publish.is_none() { return Err(AppError::Conflict("publication_off")); } crate::publish::retry_now(&state).await?; tracing::info!(by = %user.id, "publication retried now"); Ok(Json(json!({ "ok": true }))) } #[derive(Deserialize)] pub struct AdminBody { admin: bool, } pub async fn set_admin( State(state): State, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { admin_only(&user)?; auth::load_identity(&state, &id).await?; if !b.admin { let others: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1 AND id != ?") .bind(&id) .fetch_one(&state.db) .await?; if others == 0 { return Err(AppError::Conflict("last_admin")); } } sqlx::query("UPDATE identities SET admin = ? WHERE id = ?").bind(b.admin).bind(&id).execute(&state.db).await?; tracing::info!(identity = %id, admin = b.admin, by = %user.id, "administrator changed"); Ok(Json(json!({ "admin": b.admin }))) } #[derive(Deserialize)] pub struct FeedbackBody { route: String, body: String, context: Option, } pub async fn send_feedback( State(state): State, User(user): User, Json(b): Json, ) -> AppResult> { let body = b.body.trim(); if body.is_empty() || body.chars().count() > 4000 || b.route.len() > 300 { return Err(AppError::Invalid("body")); } let context = b.context.map(|c| c.to_string()).filter(|c| c.len() < 4000); sqlx::query("INSERT INTO feedback (identity_id, route, body, context, created_at) VALUES (?, ?, ?, ?, ?)") .bind(&user.id) .bind(&b.route) .bind(body) .bind(context) .bind(util::now()) .execute(&state.db) .await?; Ok(Json(json!({ "ok": true }))) } pub async fn feedback_list(State(state): State, User(user): User) -> AppResult> { admin_only(&user)?; #[derive(sqlx::FromRow, serde::Serialize)] #[serde(rename_all = "camelCase")] struct Row { id: i64, author: Option, route: String, body: String, context: Option, created_at: String, status: String, } let rows = sqlx::query_as::<_, Row>( "SELECT f.id, i.display_name AS author, f.route, f.body, f.context, f.created_at, f.status FROM feedback f LEFT JOIN identities i ON i.id = f.identity_id ORDER BY f.id DESC LIMIT 500", ) .fetch_all(&state.db) .await?; Ok(Json(json!({ "feedback": rows }))) } #[derive(Deserialize)] pub struct StatusBody { status: String, } pub async fn feedback_status( State(state): State, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { admin_only(&user)?; if !matches!(b.status.as_str(), "new" | "seen" | "done") { return Err(AppError::Invalid("status")); } sqlx::query("UPDATE feedback SET status = ? WHERE id = ?").bind(&b.status).bind(id).execute(&state.db).await?; Ok(Json(json!({ "ok": true }))) }