Files
decision/crates/ld-server/src/access.rs
T
YvvandClaude Opus 5.5 c2669a3d0b v3 : publication robuste et visible dans trustinWallet
- Clé de publication ed25519 générée au démarrage dans le volume ; sa partie
  publique et l'état de la file d'envoi s'affichent dans /acces.
- Publication initiale à l'instanciation d'un document seedé publiable.
- Reprise au démarrage : chaque document publié repasse une fois dans la file ;
  la date publiée est celle du dernier changement, un document inchangé ne
  fait pas de commit (testé contre un dépôt nu).
- Le travailleur fait un premier passage sans attendre ; journaux sans codes
  couleur hors terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:50:22 +02:00

249 lines
8.6 KiB
Rust

//! 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<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
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<String>)> =
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
let invitations: Vec<(String, String, bool, String, String, Option<String>)> = 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 publication = json!({
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
"publicKey": crate::publish::public_key(&state),
});
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::<Vec<_>>(),
})).collect::<Vec<_>>(),
"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()),
})).collect::<Vec<_>>(),
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
"id": hash, "label": label, "admin": admin,
"groups": serde_json::from_str::<Vec<String>>(groups).unwrap_or_default(),
"expiresAt": expires, "identityId": identity,
})).collect::<Vec<_>>(),
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GrantBody {
identity_id: String,
}
pub async fn grant(
State(state): State<AppState>,
User(user): User,
Path(slug): Path<String>,
Json(b): Json<GrantBody>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
User(user): User,
Path((slug, identity)): Path<(String, String)>,
) -> AppResult<Json<serde_json::Value>> {
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<String>,
identity_id: Option<String>,
}
/// The link is returned once and never stored in clear.
pub async fn invite(
State(state): State<AppState>,
User(user): User,
Json(b): Json<InviteBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
let label = b.label.trim();
if label.is_empty() || label.chars().count() > 60 {
return Err(AppError::Invalid("label"));
}
if let Some(id) = &b.identity_id {
auth::load_identity(&state, id).await?;
}
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<AppState>,
User(user): User,
Path(hash): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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 })))
}
#[derive(Deserialize)]
pub struct AdminBody {
admin: bool,
}
pub async fn set_admin(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<AdminBody>,
) -> AppResult<Json<serde_json::Value>> {
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<serde_json::Value>,
}
pub async fn send_feedback(
State(state): State<AppState>,
User(user): User,
Json(b): Json<FeedbackBody>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
#[derive(sqlx::FromRow, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Row {
id: i64,
author: Option<String>,
route: String,
body: String,
context: Option<String>,
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<AppState>,
User(user): User,
Path(id): Path<i64>,
Json(b): Json<StatusBody>,
) -> AppResult<Json<serde_json::Value>> {
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 })))
}