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 | |
| 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.
| -rw-r--r-- | docs/access.md | 53 | ||||
| -rw-r--r-- | docs/openapi.yaml | 180 | ||||
| -rw-r--r-- | docs/trust.md | 2 | ||||
| -rw-r--r-- | src/access/registry.rs | 54 | ||||
| -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 | ||||
| -rw-r--r-- | src/node.rs | 2 | ||||
| -rw-r--r-- | src/swarm/bootstrap.rs | 6 |
10 files changed, 681 insertions, 120 deletions
diff --git a/docs/access.md b/docs/access.md index 519113c..db86e21 100644 --- a/docs/access.md +++ b/docs/access.md @@ -36,8 +36,8 @@ A swarm gets a registry from its start if its genesis node is given its first us `--bootstrap-users`: it records them, with the swarm ID, in its first change, each with everything (`[]` and `["_continuum"]`). Other users, of its `--htpasswd` or any node's, may do nothing until added (the genesis node warns of those of its `--htpasswd`). -Without it, the swarm has no registry, and is as swarms were before: users all alike -(An existing swarm, below), until someone gives it one. `--protocol-writers` counts in +Without it, the swarm has no registry: its users are all alike (A swarm without a +registry, below), until someone gives it one. `--protocol-writers` counts in both: without a registry, as who may write protocol data; with one, to set it right should nobody be left who may manage users (Keeping the swarm manageable, below). @@ -73,24 +73,47 @@ A user the node lets in but the registry doesn't have may do nothing (`403`). whole swarm (its members, its remakes, its users), so it is given apart, never by accident with the data. A user who may do both has both. -## An existing swarm +## Managing users -A swarm whose document has no registry (one from before it, `_continuum/users` -missing) goes on as before: every user a node lets in may read and write the swarm's -data and use the admin endpoints, and only those in `--protocol-writers` write -protocol data. It gets a registry once a protocol writer writes one, through the store: +Through `/admin/users` ([`openapi.yaml`](openapi.yaml), Users): looking needs `read` on +`_continuum/users`, changing `write` on it. Each change is one change to the document, +logged and named by who made it. `POST /admin/users` adds several at once (below). ```sh -curl -u alice --request PUT --header 'Content-Type: application/json' \ - --data '{"since": 0, "by": "user:alice", "disabled": false, - "caps": {"1": {"path": [], "rights": ["read", "write"]}, - "2": {"path": ["_continuum"], "rights": ["read", "write"]}}}' \ - http://localhost:3000/store/_continuum/users/alice +curl -u alice http://localhost:3000/admin/users # who, and what each may do +curl -u alice -X POST -H 'Content-Type: application/json' \ + --data '{"caps": [{"path": ["servers", "eu"], "rights": ["read"]}]}' \ + http://localhost:3000/admin/users/bob # add bob, reading servers/eu +curl -u alice -X POST -H 'Content-Type: application/json' \ + --data '{"path": [], "rights": ["read", "write"]}' \ + http://localhost:3000/admin/users/bob/caps # grant: answers its ID +curl -u alice -X DELETE http://localhost:3000/admin/users/bob/caps/ID # revoke it +curl -u alice -X PUT -H 'Content-Type: application/json' --data true \ + http://localhost:3000/admin/users/bob/disabled # disable bob ``` -From that write on, every node serves only the users the registry has, so the first -entry must be the writer's own, with `["_continuum"]` (a first write that isn't is -refused, `409`); add the other users the same way. +Disabling a user keeps who wrote what readable; removing it (`DELETE`) doesn't. The +registry is protocol data in the store too, so a user who may write `_continuum/users` +may also edit its entries there directly; the same checks apply. + +## A swarm without a registry + +In a swarm whose document has no registry (`_continuum/users`), started without +`--bootstrap-users`, every user a node lets in may read and write the swarm's data and +use the admin endpoints, and only those in `--protocol-writers` write protocol data. It +gets a registry once a protocol writer adds its users: + +```sh +curl -u alice -X POST -H 'Content-Type: application/json' --data '{ + "alice": {"caps": [{"path": [], "rights": ["read", "write"]}, + {"path": ["_continuum"], "rights": ["read", "write"]}]}, + "bob": {"caps": [{"path": [], "rights": ["read", "write"]}]} + }' http://localhost:3000/admin/users +``` + +From that change on, every node serves only the users the registry has, so the caller +must be among them, with `write` on `_continuum` (else `409`): list every node's users +first. ## Keeping the swarm manageable diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 49e0042..5bf5a50 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -38,6 +38,12 @@ tags: description: | Replacing the swarm's document with a new epoch (compaction, replacement, import), and what happened to local writes that a remake couldn't place. + - name: Users + description: | + The swarm's users and what each may do (docs/access.md): reading needs `read` on + `_continuum/users`, changing `write` on it (in a swarm without a registry, being in + `protocol-writers`). A change that would keep its maker from managing users is + refused (`409`). - name: Debug description: Test hooks, only with `--debug-api`. @@ -727,6 +733,152 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /admin/users: + get: + tags: [Users] + summary: List the users + responses: + '200': + description: Whether the swarm has a registry, and every user's entry (docs/access.md) with its `name` and whether it may manage users (`manages`). + content: + application/json: + schema: + type: object + properties: + registry: { type: boolean } + users: + type: array + items: { $ref: '#/components/schemas/User' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: May not read `_continuum/users`. + post: + tags: [Users] + summary: Add several users at once + description: | + By name, each with the capabilities `POST /admin/users/{name}` takes; none of + them may exist yet. How a swarm without a registry gets one: then the caller + must be among them, with `write` on `_continuum`. + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + caps: + type: array + items: { $ref: '#/components/schemas/CapabilityRequest' } + example: + alice: { caps: [{ path: [], rights: [read, write] }, { path: [_continuum], rights: [read, write] }] } + bob: { caps: [{ path: [], rights: [read, write] }] } + responses: + '201': { description: Added. } + '400': { description: A name that isn't one, a capability that isn't one, or nobody. } + '401': + $ref: '#/components/responses/Unauthorized' + '403': { description: May not manage users. } + '409': { description: A user exists, or the caller wouldn't manage users in a new registry. } + + /admin/users/{name}: + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + get: + tags: [Users] + summary: One user + responses: + '200': + description: Its entry. + content: + application/json: + schema: { $ref: '#/components/schemas/User' } + '404': { description: No such user. } + post: + tags: [Users] + summary: Add a user + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + caps: + type: array + items: { $ref: '#/components/schemas/CapabilityRequest' } + example: { caps: [{ path: [servers, eu], rights: [read] }] } + responses: + '201': { description: Added. } + '400': { description: Not a user name, or a capability that isn't one. } + '403': { description: May not manage users. } + '409': { description: The user exists. } + delete: + tags: [Users] + summary: Remove a user + description: Disabling it (`PUT …/disabled`) keeps who wrote what readable, and is usually the better choice. + responses: + '204': { description: Removed. } + '403': { description: May not manage users. } + '404': { description: No such user. } + '409': { description: It would keep the caller from managing users. } + + /admin/users/{name}/disabled: + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + put: + tags: [Users] + summary: Disable or enable a user + requestBody: + required: true + content: + application/json: + schema: { type: boolean } + responses: + '204': { description: Done. } + '403': { description: May not manage users. } + '404': { description: No such user. } + '409': { description: It would keep the caller from managing users. } + + /admin/users/{name}/caps: + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + post: + tags: [Users] + summary: Grant a capability + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CapabilityRequest' } + responses: + '201': + description: Granted, under this ID. + content: + application/json: + schema: + type: object + properties: + id: { type: string } + '400': { description: A capability that isn't one. } + '403': { description: May not manage users. } + '404': { description: No such user. } + + /admin/users/{name}/caps/{id}: + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - { name: id, in: path, required: true, schema: { type: string } } + delete: + tags: [Users] + summary: Revoke a capability + responses: + '204': { description: Revoked. } + '403': { description: May not manage users. } + '404': { description: No such capability. } + '409': { description: It would keep the caller from managing users. } + /admin/dashboard: get: tags: [Admin] @@ -1485,6 +1637,34 @@ components: $ref: '#/components/schemas/Error' schemas: + CapabilityRequest: + type: object + required: [path, rights] + properties: + path: + type: array + items: { type: string } + description: Segments, as the store's API takes them (`[]`, all of the swarm's data; `["_continuum"]`, protocol data). + rights: + type: array + items: { type: string, enum: [read, write] } + User: + type: object + properties: + name: { type: string } + since: { type: integer, description: Unix ms. } + by: { type: string, description: 'Who added it: `user:<name>`, or a node ID.' } + disabled: { type: boolean } + manages: { type: boolean, description: Whether it may manage users. } + caps: + type: object + additionalProperties: + type: object + properties: + path: { type: array, items: { type: string } } + rights: { type: array, items: { type: string } } + by: { type: string } + since: { type: integer } Error: type: object required: [error] diff --git a/docs/trust.md b/docs/trust.md index bcf9bc7..fd37638 100644 --- a/docs/trust.md +++ b/docs/trust.md @@ -43,7 +43,7 @@ reverse proxy). It listens on `127.0.0.1:3000` by default (`--bind-addr`) and sp plain HTTP: to expose it, put a TLS proxy in front. What each user may do is the swarm's: its user registry, in the document, gives each capabilities by path, to read and to write, protocol data apart ([`access.md`](access.md)); a user the registry doesn't have -may do nothing. In a swarm without a registry (one from before it), all users may read +may do nothing. In a swarm without a registry (started without one), all users may read and write user data and use the admin endpoints, and only those in `--protocol-writers` write protocol data. Epoch metadata and the swarm ID are never writable. The `/debug` endpoints exist only with `--debug-api`. 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, ®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) +} 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), |
