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 | |
| 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')
| -rw-r--r-- | src/api/access.rs | 79 | ||||
| -rw-r--r-- | src/api/mod.rs | 15 | ||||
| -rw-r--r-- | src/api/store.rs | 66 | ||||
| -rw-r--r-- | src/api/users.rs | 344 |
4 files changed, 438 insertions, 66 deletions
diff --git a/src/api/access.rs b/src/api/access.rs index ade6542..63aa87b 100644 --- a/src/api/access.rs +++ b/src/api/access.rs @@ -20,7 +20,7 @@ use crate::state::AppState; /// What the user of a request may do (a request extension, set by `authorize`). #[derive(Clone, Debug)] pub(crate) enum Access { - /// The swarm has no registry (one from before it, `access.md`): every user the + /// The swarm has no registry (`access.md`, A swarm without a registry): every user the /// node lets in may do what all could before; protocol data only those in /// `--protocol-writers` (`store::parse_write_path`). Everything, @@ -138,6 +138,70 @@ pub(crate) fn prune_shallow(mut value: JsonValue, path: &[String], access: &Acce value } +/// The registry as a write found it (`registry_kept`): whether it existed, and whether +/// the writing user could manage users (`registry::managers`). +#[derive(Clone, Copy)] +pub(crate) struct Before { + existed: bool, + managed: bool, +} + +impl Before { + pub(crate) fn of(doc: &impl crate::document::ReadDoc, user: &str) -> Self { + use crate::access::registry; + let existed = registry::exists(doc); + Self { + existed, + managed: existed + && registry::caps_of(doc, user) + .allows(crate::access::Right::Write, ®istry::users_dir()), + } + } +} + +/// `409` for a write to `path` by `user` that would lock that user out of managing +/// users: one that could and no longer may (deleting, disabling or narrowing its own +/// entry), and the write that makes the registry, if it doesn't make `user` one who may +/// (from then on only registered users are served). And for one that would remove +/// the registry, back to a swarm without one. Checked in the write's transaction, +/// after it: the write is dropped whole. Others are not kept from removing the last who +/// may manage users: no node could keep two such writes at once on two nodes from +/// leaving nobody once they merge; then the users in `--protocol-writers` may do +/// everything again (`access::authorize`). +pub(crate) fn registry_kept( + doc: &impl crate::document::ReadDoc, + path: &[String], + before: Before, + user: &str, +) -> Result<(), super::ApiError> { + use crate::access::registry; + if !crate::document::store::paths_related(path, ®istry::users_dir()) { + return Ok(()); + } + let refuse = |why: &str| { + Err(super::ApiError( + StatusCode::CONFLICT, + Some(format!("{why} (docs/access.md)")), + )) + }; + if !registry::exists(doc) { + return if before.existed { + refuse("this would remove the user registry") + } else { + Ok(()) + }; + } + let manages = + registry::caps_of(doc, user).allows(crate::access::Right::Write, ®istry::users_dir()); + if !before.existed && !manages { + return refuse("the registry's first users must include you, with write on _continuum"); + } + if before.managed && !manages { + return refuse("this would keep you from managing users"); + } + Ok(()) +} + /// What an endpoint needs (`needed`). #[derive(Debug, PartialEq, Eq)] enum Need { @@ -164,6 +228,14 @@ fn needed(method: &Method, path: &str) -> Need { Need::Observe } else if under("/peers") { protocol(Right::Write, &[meta::FORGOTTEN]) + } else if under("/admin/users") { + // Changing them is checked by their handlers too (`users::may_manage`): in a + // swarm without a registry, `--protocol-writers`. + if looking { + protocol(Right::Read, &[meta::USERS]) + } else { + protocol(Right::Write, &[meta::USERS]) + } } else if under("/admin/remakes") { if looking { protocol(Right::Read, &[meta::REMAKES]) @@ -296,6 +368,11 @@ mod tests { write(&[meta::REMAKES]) ); assert_eq!(needed(&Method::GET, "/admin/config"), read(&[])); + assert_eq!( + needed(&Method::GET, "/admin/users/alice"), + read(&[meta::USERS]) + ); + assert_eq!(needed(&Method::POST, "/admin/users"), write(&[meta::USERS])); assert_eq!(needed(&Method::GET, "/admin/quarantine/q"), read(&[])); assert_eq!( needed(&Method::POST, "/admin/quarantine/q/replay"), diff --git a/src/api/mod.rs b/src/api/mod.rs index 3e944dc..03b4fdd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -15,6 +15,7 @@ mod log; mod peers; mod stats; mod store; +mod users; use axum::{ Json, Router, @@ -61,6 +62,20 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .route("/admin/changes", get(changes::get_changes)) .route("/admin/changes/{*key}", get(changes::get_changes_at)) .route("/admin/config", get(config::get_config)) + .route("/admin/users", get(users::list).post(users::add_several)) + .route( + "/admin/users/{name}", + get(users::get).post(users::add).delete(users::remove), + ) + .route( + "/admin/users/{name}/disabled", + axum::routing::put(users::set_disabled), + ) + .route( + "/admin/users/{name}/caps", + axum::routing::post(users::grant), + ) + .route("/admin/users/{name}/caps/{id}", delete(users::revoke)) .merge(crate::remake::router::<AppState>()); #[cfg(feature = "dashboard")] diff --git a/src/api/store.rs b/src/api/store.rs index e1f9b53..48fc2c6 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; use tokio::sync::broadcast; use tracing::{debug, error, instrument, warn}; -use super::access::{Access, Visible, prune, prune_shallow}; +use super::access::{Access, Before, Visible, prune, prune_shallow, registry_kept}; use super::auth::AuthenticatedUser; use super::{ApiError, store_status}; use crate::document::meta; @@ -468,70 +468,6 @@ fn parse_write_path( Ok(path) } -/// The registry as a write found it (`registry_kept`): whether it existed, and whether -/// the writing user could manage users (`registry::managers`). -#[derive(Clone, Copy)] -struct Before { - existed: bool, - managed: bool, -} - -impl Before { - fn of(doc: &impl crate::document::ReadDoc, user: &str) -> Self { - use crate::access::registry; - let existed = registry::exists(doc); - Self { - existed, - managed: existed - && registry::caps_of(doc, user) - .allows(crate::access::Right::Write, ®istry::users_dir()), - } - } -} - -/// `409` for a write to `path` by `user` that would lock that user out of managing -/// users: one that could and no longer may (deleting, disabling or narrowing its own -/// entry), and the write that makes the registry, if it doesn't make `user` one who may -/// (from then on only registered users are served). And for one that would remove -/// the registry, back to a swarm without one. Checked in the write's transaction, -/// after it: the write is dropped whole. Others are not kept from removing the last who -/// may manage users: no node could keep two such writes at once on two nodes from -/// leaving nobody once they merge; then the users in `--protocol-writers` may do -/// everything again (`access::authorize`). -fn registry_kept( - doc: &impl crate::document::ReadDoc, - path: &[String], - before: Before, - user: &str, -) -> Result<(), ApiError> { - use crate::access::registry; - if !store::paths_related(path, ®istry::users_dir()) { - return Ok(()); - } - let refuse = |why: &str| { - Err(ApiError( - StatusCode::CONFLICT, - Some(format!("{why} (docs/access.md)")), - )) - }; - if !registry::exists(doc) { - return if before.existed { - refuse("this would remove the user registry") - } else { - Ok(()) - }; - } - let manages = - registry::caps_of(doc, user).allows(crate::access::Right::Write, ®istry::users_dir()); - if !before.existed && !manages { - return refuse("the registry's first users must include you, with write on _continuum"); - } - if before.managed && !manages { - return refuse("this would keep you from managing users"); - } - Ok(()) -} - /// How a value is shown, as the request's `Accept` asks. #[derive(Clone, Copy, Debug)] enum Shown { 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) +} |
