aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/users.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:09 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:09 +0100
commitde12c862b3f4e69290c8e93b3b4520024ba458f6 (patch)
tree0f56381fa62948a7cb11b03fed6075fd85e7e649 /src/api/users.rs
parent3a9d9da74f55395ead20f3915ed04639e37b2f03 (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.rs344
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, &registry::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, &registry::user_path(name)).ok()??;
+ let manages = registry::caps_of(doc, name).allows(Right::Write, &registry::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, &registry::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, &registry::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,
+ &registry::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, &registry::entry(by, since, &caps))?;
+ }
+ Ok(())
+ })
+ .await?;
+ Ok(StatusCode::CREATED)
+}