diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
| commit | de12c862b3f4e69290c8e93b3b4520024ba458f6 (patch) | |
| tree | 0f56381fa62948a7cb11b03fed6075fd85e7e649 /src/api/users.rs | |
| parent | 3a9d9da74f55395ead20f3915ed04639e37b2f03 (diff) | |
access: the users endpoints
/admin/users: list, add (one or several), remove, disable, grant and
revoke capabilities; each one change, refused if it would lock its
writer out. Capabilities as the store's paths name them.
Auth plan M1.
Diffstat (limited to 'src/api/users.rs')
| -rw-r--r-- | src/api/users.rs | 344 |
1 files changed, 344 insertions, 0 deletions
diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..e164ecd --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,344 @@ +//! `/admin/users`: the swarm's users and what each may do (`access.md`), managed +//! through the API rather than by writing the registry's entries through the store. +//! +//! * `GET /admin/users`: every user, its entry and whether it may manage users. +//! * `GET /admin/users/{name}`: one. +//! * `POST /admin/users/{name}`: adds a user, with the capabilities of the body +//! (`{"caps": [{"path": [...], "rights": ["read", "write"]}, …]}`, none if absent); +//! `409` if it exists. +//! * `DELETE /admin/users/{name}`: removes a user (`disabled` keeps who wrote what +//! readable: usually the better choice). +//! * `PUT /admin/users/{name}/disabled`: `true` or `false`. +//! * `POST /admin/users/{name}/caps`: grants a capability (`{"path": […], "rights": +//! […]}`), answered with its ID; `DELETE /admin/users/{name}/caps/{id}` revokes one. +//! * `POST /admin/users`: adds several at once, by name, each with the capabilities +//! `POST /admin/users/{name}` takes (`{"alice": {"caps": […]}, "bob": {…}}`); `409` if +//! one exists. How a swarm without a registry gets one, the caller among those who +//! may manage users. +//! +//! Looking needs `read` on `_continuum/users`, changing `write` on it (in a swarm +//! without a registry, being in `--protocol-writers`). Every change is one change to +//! the document, logged, named by who made it, and refused (`409`) where it would lock +//! its maker out (`access::registry_kept`). + +use axum::{ + Extension, Json, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; +use tracing::info; + +use super::ApiError; +use super::access::{Access, Before, registry_kept}; +use super::auth::AuthenticatedUser; +use crate::access::{Right, registry}; +use crate::document::store; +use crate::state::AppState; + +/// A capability as a request gives it. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CapRequest { + path: Vec<String>, + rights: Vec<String>, +} + +impl CapRequest { + fn rights(&self) -> Result<Vec<Right>, ApiError> { + if self.rights.is_empty() { + return Err(bad("a capability gives at least one right")); + } + self.rights + .iter() + .map(|right| match right.as_str() { + "read" => Ok(Right::Read), + "write" => Ok(Right::Write), + other => Err(bad(&format!("no right {other:?}: read or write"))), + }) + .collect() + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AddRequest { + #[serde(default)] + caps: Vec<CapRequest>, +} + +fn bad(message: &str) -> ApiError { + ApiError(StatusCode::BAD_REQUEST, Some(message.to_owned())) +} + +fn named(name: &str) -> Result<(), ApiError> { + if registry::valid_name(name) { + Ok(()) + } else { + Err(bad("not a user name: 1 to 64 of A-Z a-z 0-9 . _ @ -")) + } +} + +/// `403` unless the caller may change the registry: `write` on `_continuum/users`, or +/// in a swarm without a registry, being in `--protocol-writers` (as for writes to +/// protocol data through the store). +fn may_manage(state: &AppState, user: &str, access: &Access) -> Result<(), ApiError> { + let allowed = match access { + Access::Everything => state.protocol_writers.contains(user), + Access::Caps(caps) => caps.allows(Right::Write, ®istry::users_dir()), + }; + if allowed { + Ok(()) + } else { + Err(ApiError( + StatusCode::FORBIDDEN, + Some("may not manage users (docs/access.md)".to_owned()), + )) + } +} + +/// A user's entry as the API shows it: as recorded, and whether it may manage users. +fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> { + let mut entry = store::read_path(doc, ®istry::user_path(name)).ok()??; + let manages = registry::caps_of(doc, name).allows(Right::Write, ®istry::users_dir()); + if let Some(object) = entry.as_object_mut() { + object.insert("name".to_owned(), json!(name)); + object.insert("manages".to_owned(), json!(manages)); + } + Some(entry) +} + +/// `GET /admin/users`. +pub(crate) async fn list(State(state): State<AppState>) -> Json<JsonValue> { + let replica = state.doc.read().await; + let users: Vec<JsonValue> = registry::names(&replica.doc) + .iter() + .filter_map(|name| shown(&replica.doc, name)) + .collect(); + Json(json!({ "registry": registry::exists(&replica.doc), "users": users })) +} + +/// `GET /admin/users/{name}`. +pub(crate) async fn get( + State(state): State<AppState>, + Path(name): Path<String>, +) -> Result<Json<JsonValue>, ApiError> { + let replica = state.doc.read().await; + shown(&replica.doc, &name) + .map(Json) + .ok_or(ApiError(StatusCode::NOT_FOUND, None)) +} + +/// Writes to the registry what `f` writes, as `user`, logged as `what`: one change, +/// dropped whole if it fails or would lock `user` out (`registry_kept`). +async fn change( + state: &AppState, + user: &str, + what: String, + f: impl FnOnce(&mut crate::document::Transaction<'_>, &str, u64) -> Result<(), ApiError> + + Send + + 'static, +) -> Result<(), ApiError> { + if !state.is_ready().await { + return Err(StatusCode::SERVICE_UNAVAILABLE.into()); + } + let by = format!("user:{user}"); + let writer = user.to_owned(); + let since = state.clock.now_millis().as_u64(); + state + .mutate_doc_for(Some(user.to_owned()), move |doc| { + let before = Before::of(&*doc, &writer); + f(doc, &by, since)?; + registry_kept(&*doc, ®istry::users_dir(), before, &writer) + }) + .await?; + info!(user = %user, "{what}"); + Ok(()) +} + +/// `POST /admin/users/{name}`. +pub(crate) async fn add( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, + Path(name): Path<String>, + body: Option<Json<AddRequest>>, +) -> Result<Response, ApiError> { + may_manage(&state, &user.0, &access)?; + named(&name)?; + let request = body.map(|Json(request)| request).unwrap_or_default(); + let caps: Vec<(Vec<String>, Vec<Right>)> = request + .caps + .iter() + .map(|cap| Ok((cap.path.clone(), cap.rights()?))) + .collect::<Result<_, ApiError>>()?; + let path = registry::user_path(&name); + change( + &state, + &user.0, + format!("Added user {name}"), + move |doc, by, since| { + if store::read_path(&*doc, &path)?.is_some() { + return Err(ApiError( + StatusCode::CONFLICT, + Some("the user exists".to_owned()), + )); + } + let caps: Vec<(Vec<String>, &[Right])> = caps + .iter() + .map(|(path, rights)| (path.clone(), rights.as_slice())) + .collect(); + store::write_path(doc, &path, ®istry::entry(by, since, &caps))?; + Ok(()) + }, + ) + .await?; + Ok(StatusCode::CREATED.into_response()) +} + +/// `DELETE /admin/users/{name}`. +pub(crate) async fn remove( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, + Path(name): Path<String>, +) -> Result<StatusCode, ApiError> { + may_manage(&state, &user.0, &access)?; + let path = registry::user_path(&name); + change( + &state, + &user.0, + format!("Removed user {name}"), + move |doc, _, _| { + store::delete_path(doc, &path, false)?; + Ok(()) + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +/// `PUT /admin/users/{name}/disabled`. +pub(crate) async fn set_disabled( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, + Path(name): Path<String>, + Json(disabled): Json<bool>, +) -> Result<StatusCode, ApiError> { + may_manage(&state, &user.0, &access)?; + let entry = registry::user_path(&name); + let what = format!( + "{} user {name}", + if disabled { "Disabled" } else { "Enabled" } + ); + change(&state, &user.0, what, move |doc, _, _| { + if store::read_path(&*doc, &entry)?.is_none() { + return Err(ApiError(StatusCode::NOT_FOUND, None)); + } + let mut path = entry; + path.push("disabled".to_owned()); + store::write_path(doc, &path, &json!(disabled))?; + Ok(()) + }) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +/// `POST /admin/users/{name}/caps`. +pub(crate) async fn grant( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, + Path(name): Path<String>, + Json(cap): Json<CapRequest>, +) -> Result<Response, ApiError> { + may_manage(&state, &user.0, &access)?; + let rights = cap.rights()?; + let id = registry::new_id(); + let entry = registry::user_path(&name); + let mut path = entry.clone(); + path.extend(["caps".to_owned(), id.clone()]); + let what = format!("Granted user {name} {:?} on {:?}", cap.rights, cap.path); + change(&state, &user.0, what, move |doc, by, since| { + if store::read_path(&*doc, &entry)?.is_none() { + return Err(ApiError(StatusCode::NOT_FOUND, None)); + } + store::write_path( + doc, + &path, + ®istry::capability(by, since, &cap.path, &rights), + )?; + Ok(()) + }) + .await?; + Ok((StatusCode::CREATED, Json(json!({ "id": id }))).into_response()) +} + +/// `DELETE /admin/users/{name}/caps/{id}`. +pub(crate) async fn revoke( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, + Path((name, id)): Path<(String, String)>, +) -> Result<StatusCode, ApiError> { + may_manage(&state, &user.0, &access)?; + let mut path = registry::user_path(&name); + path.extend(["caps".to_owned(), id.clone()]); + change( + &state, + &user.0, + format!("Revoked capability {id} of user {name}"), + move |doc, _, _| { + store::delete_path(doc, &path, false)?; + Ok(()) + }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +/// `POST /admin/users`. +pub(crate) async fn add_several( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, + Json(request): Json<std::collections::BTreeMap<String, AddRequest>>, +) -> Result<StatusCode, ApiError> { + may_manage(&state, &user.0, &access)?; + if request.is_empty() { + return Err(bad("nobody to add")); + } + let mut users = Vec::new(); + for (name, add) in &request { + named(name)?; + let caps: Vec<(Vec<String>, Vec<Right>)> = add + .caps + .iter() + .map(|cap| Ok((cap.path.clone(), cap.rights()?))) + .collect::<Result<_, ApiError>>()?; + users.push((name.clone(), caps)); + } + let what = format!("Added users {:?}", request.keys().collect::<Vec<_>>()); + change(&state, &user.0, what, move |doc, by, since| { + for (name, caps) in &users { + let path = registry::user_path(name); + if store::read_path(&*doc, &path)?.is_some() { + return Err(ApiError( + StatusCode::CONFLICT, + Some(format!("user {name} exists")), + )); + } + let caps: Vec<(Vec<String>, &[Right])> = caps + .iter() + .map(|(path, rights)| (path.clone(), rights.as_slice())) + .collect(); + store::write_path(doc, &path, ®istry::entry(by, since, &caps))?; + } + Ok(()) + }) + .await?; + Ok(StatusCode::CREATED) +} |
