aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/access/registry.rs54
-rw-r--r--src/api/access.rs79
-rw-r--r--src/api/mod.rs15
-rw-r--r--src/api/store.rs66
-rw-r--r--src/api/users.rs344
-rw-r--r--src/node.rs2
-rw-r--r--src/swarm/bootstrap.rs6
7 files changed, 462 insertions, 104 deletions
diff --git a/src/access/registry.rs b/src/access/registry.rs
index 3006b31..9eafc82 100644
--- a/src/access/registry.rs
+++ b/src/access/registry.rs
@@ -33,7 +33,7 @@ pub(crate) fn user_path(name: &str) -> Vec<String> {
}
/// Whether the swarm has a registry: until it has, every user a node lets in may do
-/// what all could before (`docs/access.md`, An existing swarm).
+/// everything but write protocol data (`docs/access.md`, A swarm without a registry).
pub(crate) fn exists(doc: &impl ReadDoc) -> bool {
store::is_map(doc, &users_dir())
}
@@ -141,28 +141,23 @@ pub(crate) fn capability(by: &str, since: u64, path: &[String], rights: &[Right]
})
}
-/// The first users of a new swarm, as its genesis node records them (`first_users`):
-/// each name, and whether it may do everything (protocol data and managing users
-/// included), not only read and write the swarm's data.
-pub(crate) type FirstUsers = Vec<(String, bool)>;
+/// The first users of a new swarm, as its genesis node records them (`first_users`),
+/// each with everything (`all_caps`).
+pub(crate) type FirstUsers = Vec<String>;
-/// The first users of a new swarm, from the genesis node's `--bootstrap-users`: each may
-/// do everything. None without: then the swarm has no registry, and its users are all
-/// alike, as before there were registries (`access.md`).
+/// The first users of a new swarm, from the genesis node's `--bootstrap-users`, sorted.
+/// None without: then the swarm has no registry, and its users are all alike
+/// (`access.md`, A swarm without a registry).
pub(crate) fn first_users(bootstrap_users: &[String]) -> FirstUsers {
let users: std::collections::BTreeSet<&String> = bootstrap_users.iter().collect();
- users.into_iter().map(|name| (name.clone(), true)).collect()
+ users.into_iter().cloned().collect()
}
-/// The capabilities of a first user (`first_users`): the swarm's data, and for one who
-/// may do everything protocol data too.
-pub(crate) fn first_caps(admin: bool) -> Vec<(Vec<String>, &'static [Right])> {
+/// Everything, as a first user has it (`first_users`): the swarm's data and protocol
+/// data (managing users included), to read and write.
+pub(crate) fn all_caps() -> Vec<(Vec<String>, &'static [Right])> {
const BOTH: &[Right] = &[Right::Read, Right::Write];
- let mut caps = vec![(Vec::new(), BOTH)];
- if admin {
- caps.push((meta::path(&[]), BOTH));
- }
- caps
+ vec![(Vec::new(), BOTH), (meta::path(&[]), BOTH)]
}
/// A random ID for a capability (8 bytes, hex): concurrent grants don't collide.
@@ -250,37 +245,28 @@ mod tests {
let names = |list: &[&str]| list.iter().map(|n| (*n).to_owned()).collect::<Vec<_>>();
assert_eq!(
first_users(&names(&["bob", "alice", "bob"])),
- vec![("alice".to_owned(), true), ("bob".to_owned(), true)]
+ names(&["alice", "bob"])
);
// Without bootstrap users, no registry.
assert!(first_users(&[]).is_empty());
- let admin = Caps::new(
- first_caps(true)
- .into_iter()
- .map(|(path, rights)| {
- serde_json::from_value(capability("x", 0, &path, rights)).unwrap()
- })
- .collect(),
- );
- assert!(admin.allows(Right::Write, &path("_continuum/users/x")));
- assert!(admin.allows(Right::Write, &path("accounts")));
- let user = Caps::new(
- first_caps(false)
+ let all = Caps::new(
+ all_caps()
.into_iter()
.map(|(path, rights)| {
serde_json::from_value(capability("x", 0, &path, rights)).unwrap()
})
.collect(),
);
- assert!(user.allows(Right::Write, &path("accounts")));
- assert!(!user.allows(Right::Read, &path("_continuum")));
+ assert!(all.allows(Right::Write, &path("_continuum/users/x")));
+ assert!(all.allows(Right::Write, &path("accounts")));
}
#[test]
fn who_may_manage_users() {
let mut doc = Document::new();
- for (name, admin) in [("alice", true), ("bob", false)] {
- let entry = entry("x", 0, &first_caps(admin));
+ const DATA: &[Right] = &[Right::Read, Right::Write];
+ for (name, caps) in [("alice", all_caps()), ("bob", vec![(Vec::new(), DATA)])] {
+ let entry = entry("x", 0, &caps);
change(&mut doc, |tx| {
store::write_path(tx, &user_path(name), &entry).unwrap()
});
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, &registry::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, &registry::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, &registry::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, &registry::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, &registry::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, &registry::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, &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)
+}
diff --git a/src/node.rs b/src/node.rs
index c172fb8..8ba1373 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -62,7 +62,7 @@ pub(crate) struct NodeConfig {
pub(crate) protocol_writers: HashSet<String>,
/// On a genesis node: the swarm's first users, which it records
/// (`access::registry::first_users`); empty elsewhere, and where none were named
- /// (the simulations: no registry, as in a swarm from before).
+ /// (the simulations: no registry).
pub(crate) first_users: crate::access::registry::FirstUsers,
pub(crate) files: Arc<Files>,
pub(crate) state_file: Option<Arc<StateFile>>,
diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs
index a7266c7..446b846 100644
--- a/src/swarm/bootstrap.rs
+++ b/src/swarm/bootstrap.rs
@@ -92,7 +92,7 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start)
let topology = serde_json::to_value(state.configured_ways.0).unwrap_or_default();
let dissemination = serde_json::to_value(state.configured_ways.1).unwrap_or_default();
// The first users, granted by this node: with the rest, so that no node sees the
- // swarm without its registry (none named: none, as in a swarm from before).
+ // swarm without its registry (none named: none).
let (by, since) = (
state.local_id().to_owned(),
state.clock.now_millis().as_u64(),
@@ -100,8 +100,8 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start)
let users: Vec<(Vec<String>, JsonValue)> = state
.first_users
.iter()
- .map(|(name, admin)| {
- let caps = crate::access::registry::first_caps(*admin);
+ .map(|name| {
+ let caps = crate::access::registry::all_caps();
(
crate::access::registry::user_path(name),
crate::access::registry::entry(&by, since, &caps),