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 | c82a62186a8e0a46fd41e08345a2fa3e54ddffe2 (patch) | |
| tree | 068e5bc12c7d13a51f7ba4a4e3e9723cbe33cc7e | |
| parent | a8ee17b7513c0367b5a9e5a30ae290fe53dad88b (diff) | |
access: authorization at the API
Every request is judged by the registry the node holds: store reads
pruned to what the user may read, writes refused where it may not,
admin endpoints by protocol capabilities. Without a registry, as
before (--protocol-writers for protocol data).
Auth plan M1.
| -rw-r--r-- | docs/access.md | 50 | ||||
| -rw-r--r-- | docs/api.md | 11 | ||||
| -rw-r--r-- | docs/dashboard.md | 6 | ||||
| -rw-r--r-- | docs/example.toml | 8 | ||||
| -rw-r--r-- | docs/openapi.yaml | 4 | ||||
| -rw-r--r-- | docs/trust.md | 18 | ||||
| -rw-r--r-- | src/api/access.rs | 362 | ||||
| -rw-r--r-- | src/api/mod.rs | 35 | ||||
| -rw-r--r-- | src/api/store.rs | 139 | ||||
| -rw-r--r-- | src/config/mod.rs | 2 | ||||
| -rw-r--r-- | src/document/store.rs | 14 |
11 files changed, 584 insertions, 65 deletions
diff --git a/docs/access.md b/docs/access.md index 8cb1ef9..042e0d5 100644 --- a/docs/access.md +++ b/docs/access.md @@ -40,3 +40,53 @@ Its genesis node records the first users, with the swarm ID, in its first change A swarm nobody could manage doesn't start: with none of them with `["_continuum"]`, `--genesis` is refused, naming both options. + +## At the HTTP API + +Every request is checked against the registry, as the node serving it holds it: a +change to it counts on a node as soon as it has reached it. + +* **The store** (`/store`, `/stream`): writing a path (`PUT`, `POST`, `DELETE`) takes + `write` on it; `?prune=true` deletes maps left empty only up to the highest one the + user may write. Reading takes `read`: of a place the user may read only partly, it + sees the keys on the way to what it may read, as a directory one may pass through + but not list (a capability on `servers/eu` shows `servers` with `eu` alone in it), + and nothing else; a list on the way is left out whole, as its items have no paths of + their own. Native forms (`text/plain` of a map, a counter as its own type) only of + what it may read whole. What a write answers with is what was written. A stream + shows what the user could read when it opened. +* **Protocol data** is in the store too, under `_continuum`: `read` and `write` on it + as on any path. +* **Looking at the node** (`/admin/stats`, `GET /peers`, the dashboard): any user with + a capability. +* **Remakes** (`/admin/remakes`): `read` on `_continuum/remakes` to look, `write` to + propose or abort one. **Forgetting a peer** (`DELETE /peers/…`): `write` on + `_continuum/forgotten`. +* **Everything else an operator does** (`/admin/config`, `/admin/log`, + `/admin/document`, `/admin/changes`, `/admin/epochs`, `/admin/quarantine`, `/debug`): + `read` on `_continuum` to look, `write` on it to act. + +A user the node lets in but the registry doesn't have may do nothing (`403`). + +`[]` and `["_continuum"]` are two capabilities on purpose: protocol data acts on the +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 + +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: + +```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 +``` + +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"]`, or nobody may change the +registry again; add the other users the same way. diff --git a/docs/api.md b/docs/api.md index cdc722e..5486378 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,7 +33,11 @@ curl -u alice:secret -X POST 'localhost:3000/store/stats/visits?increment=1' # asks. Numbers in JSON are stored as integers where they fit, as floats otherwise. * Every request needs a user from the node's `--htpasswd` (basic authentication), or named by a reverse proxy in front that authenticated it (`--user-header`; - [`operations.md`](operations.md), Behind a reverse proxy). Users are all alike: each can read and write everything but protocol data (below). + [`operations.md`](operations.md), Behind a reverse proxy). What each may do, the swarm's user + registry says, by path ([`access.md`](access.md)): `403` where it doesn't let the user, and + of a part it may only partly read, a read shows the keys on the way to what it may. + In a swarm without a registry, users are all alike: each can read and write + everything but protocol data (below). * A key with `/` in it, or an empty key, can be written inside a value at its parent, but not named in a path: read it through the parent. @@ -238,8 +242,9 @@ who wrote what, until a remake drops the history ([`remakes.md`](remakes.md)). `_continuum` holds what the nodes coordinate through: the swarm ID, the member registry, remakes and votes ([`architecture.md`](architecture.md), The document). It is part of the store and shows -in `GET /store`, but isn't application data: only users in `--protocol-writers` may -write it, for repairs. Keep application data out of it, and leave it out when reading +in `GET /store`, but isn't application data: only users given it may write it +([`access.md`](access.md); in a swarm without a user registry, those in `--protocol-writers`), for +repairs. Keep application data out of it, and leave it out when reading the whole store if it's in the way. ## Large stores diff --git a/docs/dashboard.md b/docs/dashboard.md index 3ac587b..9879fb5 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -273,8 +273,10 @@ to the whole swarm like any other: * **`+` on a list's row** inserts an item at an index (`-1`: at the end); the items from there on move up. -Protocol data (`_continuum`) is written only by users in `--protocol-writers`, and epoch -metadata and the swarm ID by nobody ([`trust.md`](trust.md)). A key containing `/`, or an empty +Each user sees and edits what its capabilities let it ([`access.md`](access.md)): of a part it +may only partly read, the keys on the way to what it may. Protocol data (`_continuum`) +is written only by users given it (in a swarm without a user registry, those in +`--protocol-writers`), and epoch metadata and the swarm ID by nobody ([`trust.md`](trust.md)). A key containing `/`, or an empty key, is listed but can't be opened. In the protocol data, values and keys get the same readings as in the figures: the aliases of node IDs (a member's entry, a vote, a coordinator), times and sizes. diff --git a/docs/example.toml b/docs/example.toml index 26be818..da808ec 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -80,9 +80,11 @@ htpasswd = "/etc/continuum/htpasswd" # trusted-proxies = ["127.0.0.1", "::1", "unix"] # HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum -# (remake records, members, forget records), e.g. to repair a stuck remake. Such writes -# act on the whole swarm and are logged. Epoch metadata and the swarm ID are never -# writable. +# (remake records, members, forget records), e.g. to repair a stuck remake, in a swarm +# without a user registry. Such writes act on the whole swarm and are logged. Epoch +# metadata and the swarm ID are never writable. With a registry its capabilities decide +# instead (docs/access.md); on a genesis node, those of htpasswd's users named here may +# do everything in the new swarm's registry. # protocol-writers = ["alice"] # With genesis: the new swarm's first users, each may do everything, protocol data and diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0193495..49e0042 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -168,7 +168,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - description: Protocol data (`_continuum`) and the user isn't in `protocol-writers`, or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable). + description: The user may not write here (its capabilities, docs/access.md; in a swarm without a user registry, protocol data `_continuum` and the user isn't in `protocol-writers`), or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable). '409': description: The path crosses an existing non-map value, or with `retype=false` the write would change a type (the message says where). '415': @@ -290,7 +290,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - description: Protocol data (`_continuum`) and the user isn't in `protocol-writers`, or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable). + description: The user may not write here (its capabilities, docs/access.md; in a swarm without a user registry, protocol data `_continuum` and the user isn't in `protocol-writers`), or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable). '404': description: No value at this path. '500': diff --git a/docs/trust.md b/docs/trust.md index 4435f99..bcf9bc7 100644 --- a/docs/trust.md +++ b/docs/trust.md @@ -40,12 +40,12 @@ requests from there can be any user. With the default loopback addresses, that i user of the host; with only the API's Unix socket (`--trusted-proxies unix`), those its file's permissions let in: the node's user and group ([`operations.md`](operations.md), Behind a reverse proxy). It listens on `127.0.0.1:3000` by default (`--bind-addr`) and speaks -plain HTTP: to expose it, put a TLS proxy in front. All users may read and write user -data and use the admin endpoints (remakes, forgetting peers, exports, log filters). -There are no read-only users: whoever may open the dashboard may also edit and delete -the document's data from it, and its writes reach the whole swarm. -Only the users in `--protocol-writers` may write protocol data through the store; epoch -metadata and the swarm ID are never writable. The `/debug` endpoints exist only with +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 +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`. **The configuration.** Whoever can write a node's configuration file or its options can @@ -65,6 +65,9 @@ node acts on what the document says: * **The member registry**: each node writes its own entry, but nothing stops a member from writing others'. The registry decides the topology and, through the alive stamps, the electorate of a remake. +* **The user registry**: written through the API only by users given protocol data, + but a member can write any entry into its own document, and every node then serves + users as it says; the checks at the API bind honest nodes only. * **Forget records**: any member can make the swarm forget a peer (until it says HELLO again). * **Remakes**: any member can propose one, vote, and, as coordinator, decide. Votes @@ -114,7 +117,8 @@ Not against a hostile member, but against honest ones getting in each other's wa ([`remakes.md`](remakes.md)). * A snapshot every node builds itself and checks against the committed epoch, and downloads checked against their hashes. -* Protocol data writable through the API only by `--protocol-writers`, and logged. +* Protocol data writable through the API only by users given it ([`access.md`](access.md)), or + in a swarm without a registry by `--protocol-writers`, and logged. ## Not yet diff --git a/src/api/access.rs b/src/api/access.rs new file mode 100644 index 0000000..8a3e630 --- /dev/null +++ b/src/api/access.rs @@ -0,0 +1,362 @@ +//! What the user of a request may do, from the user registry (`access`), checked on +//! every request ([`access.md`](../../docs/access.md)): the endpoints by `authorize`, +//! the store's paths by its handlers, through the `Access` it leaves on the request. + +use axum::{ + body::Body, + extract::State, + http::{Method, Request, StatusCode}, + response::Response, +}; +use serde_json::Value as JsonValue; +use tracing::debug; + +use super::auth::AuthenticatedUser; +use super::error_response; +use crate::access::{Caps, Right, registry}; +use crate::document::meta; +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 + /// node lets in may do what all could before; protocol data only those in + /// `--protocol-writers` (`store::parse_write_path`). + Everything, + /// What its registry entry gives. + Caps(Caps), +} + +/// How much of a place a user may read (`Access::visible`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Visible { + /// It and all below it. + All, + /// Only what lies below it on the way to what it may read (a map's keys leading + /// there, as a directory one may pass through but not list): `prune`. + Partly, + Nothing, +} + +impl Access { + pub(crate) fn allows(&self, right: Right, path: &[String]) -> bool { + match self { + Self::Everything => true, + Self::Caps(caps) => caps.allows(right, path), + } + } + + pub(crate) fn visible(&self, path: &[String]) -> Visible { + match self { + Self::Everything => Visible::All, + // The root holds protocol data too, which a capability on `[]` doesn't give. + Self::Caps(caps) + if caps.allows(Right::Read, path) + && (!path.is_empty() || caps.allows(Right::Read, &meta::path(&[]))) => + { + Visible::All + } + Self::Caps(caps) if caps.reaches(Right::Read, path) => Visible::Partly, + Self::Caps(_) => Visible::Nothing, + } + } +} + +/// `value`, read at `path`, as much of it as `access` lets its user read: of a map it +/// may only partly read, the keys on the way to what it may (recursively); a list, a +/// text or a scalar it may only partly read is left out, as a list's items have no path +/// of their own to give rights to. `typed`: `value` is typed JSON +/// (`typed::JsonForm::typed`), whose maps may be tagged (`{"$map": {…}}`) and whose +/// tagged values (`{"$uint": 5}`) are scalars. None if nothing of it is readable. +pub(crate) fn prune( + value: JsonValue, + path: &mut Vec<String>, + access: &Access, + typed: bool, +) -> Option<JsonValue> { + match access.visible(path) { + Visible::All => return Some(value), + Visible::Nothing => return None, + Visible::Partly => {} + } + let JsonValue::Object(map) = value else { + return None; + }; + let tag = |map: &serde_json::Map<String, JsonValue>| { + (typed && map.len() == 1) + .then(|| { + map.keys() + .next() + .filter(|key| key.starts_with('$')) + .cloned() + }) + .flatten() + }; + let map = match tag(&map) { + None => map, + Some(tag) if tag == "$map" => match map.into_iter().next() { + Some((_, JsonValue::Object(inner))) => inner, + _ => return None, + }, + // A tagged scalar or text. + Some(_) => return None, + }; + let mut out = serde_json::Map::new(); + for (key, child) in map { + path.push(key.clone()); + let child = prune(child, path, access, typed); + path.pop(); + if let Some(child) = child { + out.insert(key, child); + } + } + // A map whose only key looks like a tag is tagged itself. + Some(match tag(&out) { + Some(_) => serde_json::json!({ "$map": out }), + None => JsonValue::Object(out), + }) +} + +/// A shallow read of `path` (`store::read_shallow`), its `entries` only those `access` +/// lets its user read at least partly. +pub(crate) fn prune_shallow(mut value: JsonValue, path: &[String], access: &Access) -> JsonValue { + if access.visible(path) == Visible::All { + return value; + } + if let Some(entries) = value.get_mut("entries").and_then(JsonValue::as_object_mut) { + let mut child = path.to_vec(); + entries.retain(|key, _| { + child.push(key.clone()); + let visible = access.visible(&child) != Visible::Nothing; + child.pop(); + visible + }); + let size = entries.len(); + value["size"] = size.into(); + } + value +} + +/// What an endpoint needs (`needed`). +#[derive(Debug, PartialEq, Eq)] +enum Need { + /// The store and its streams: their handlers check each path. + Store, + /// Looking at this node (its figures, peers, dashboard): any capability. + Observe, + /// A right on protocol data. + Protocol(Right, Vec<String>), +} + +/// What a request to `path` by `method` needs. Remakes' records and forget records +/// are protocol data of their own: proposing or aborting a remake needs `write` on +/// `_continuum/remakes`, forgetting a peer on `_continuum/forgotten`; everything else +/// this node's operators do (its log filter, exports, quarantine, the changes) needs +/// protocol data itself, reading it to look, writing it to act. +fn needed(method: &Method, path: &str) -> Need { + let looking = method == Method::GET || method == Method::HEAD; + let protocol = |right, parts: &[&str]| Need::Protocol(right, meta::path(parts)); + let under = |prefix: &str| path == prefix || path.starts_with(&format!("{prefix}/")); + if under("/store") || under("/stream") { + Need::Store + } else if (path == "/peers" && looking) || path == "/admin/stats" || under("/admin/dashboard") { + Need::Observe + } else if under("/peers") { + protocol(Right::Write, &[meta::FORGOTTEN]) + } else if under("/admin/remakes") { + if looking { + protocol(Right::Read, &[meta::REMAKES]) + } else { + protocol(Right::Write, &[meta::REMAKES]) + } + } else if looking && !under("/debug") { + protocol(Right::Read, &[]) + } else { + protocol(Right::Write, &[]) + } +} + +/// Finds what the request's user may do, refuses the request if its endpoint needs +/// more (`needed`), and leaves `Access` on it for the store's handlers. Inside +/// `auth::basic_auth`, which found the user. A user the registry doesn't have may do +/// nothing: `403`. +pub(crate) async fn authorize( + State(state): State<AppState>, + mut request: Request<Body>, + next: axum::middleware::Next, +) -> Response { + let Some(AuthenticatedUser(user)) = request.extensions().get::<AuthenticatedUser>().cloned() + else { + return error_response(StatusCode::UNAUTHORIZED, "not authenticated", None); + }; + let access = { + let replica = state.doc.read().await; + if !registry::exists(&replica.doc) { + Access::Everything + } else { + match registry::user(&replica.doc, &user) { + Some(entry) => Access::Caps(entry.caps()), + None => { + debug!(user = %user, "Refused a user the registry doesn't have"); + return error_response( + StatusCode::FORBIDDEN, + format!("{user} is not a registered user (docs/access.md)"), + None, + ); + } + } + } + }; + let allowed = match needed(request.method(), request.uri().path()) { + Need::Store => true, + Need::Observe => match &access { + Access::Everything => true, + Access::Caps(caps) => !caps.is_empty(), + }, + Need::Protocol(right, path) => access.allows(right, &path), + }; + if !allowed { + debug!(user = %user, method = %request.method(), path = %request.uri().path(), "Refused a request the user's capabilities don't cover"); + return error_response( + StatusCode::FORBIDDEN, + format!("{user} may not do that here (docs/access.md)"), + None, + ); + } + request.extensions_mut().insert(access); + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::access::Capability; + use serde_json::json; + + fn path(text: &str) -> Vec<String> { + text.split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_owned) + .collect() + } + + fn caps(list: &[(&str, &[&str])]) -> Access { + Access::Caps(Caps::new( + list.iter() + .map(|(at, rights)| Capability { + path: path(at), + rights: rights.iter().map(|right| (*right).to_owned()).collect(), + by: String::new(), + since: 0, + }) + .collect(), + )) + } + + #[test] + fn endpoints_and_what_they_need() { + let read = |parts: &[&str]| Need::Protocol(Right::Read, meta::path(parts)); + let write = |parts: &[&str]| Need::Protocol(Right::Write, meta::path(parts)); + assert_eq!(needed(&Method::GET, "/store"), Need::Store); + assert_eq!(needed(&Method::PUT, "/store/a/b"), Need::Store); + assert_eq!(needed(&Method::GET, "/stream/a"), Need::Store); + assert_eq!(needed(&Method::GET, "/storehouse"), read(&[])); + assert_eq!(needed(&Method::GET, "/peers"), Need::Observe); + assert_eq!(needed(&Method::GET, "/admin/stats"), Need::Observe); + assert_eq!( + needed(&Method::GET, "/admin/dashboard/state"), + Need::Observe + ); + assert_eq!( + needed(&Method::POST, "/admin/dashboard/refresh"), + Need::Observe + ); + assert_eq!( + needed(&Method::DELETE, "/peers/continuum:00"), + write(&[meta::FORGOTTEN]) + ); + assert_eq!( + needed(&Method::GET, "/admin/remakes/r1"), + read(&[meta::REMAKES]) + ); + assert_eq!( + needed(&Method::POST, "/admin/remakes"), + write(&[meta::REMAKES]) + ); + assert_eq!( + needed(&Method::DELETE, "/admin/remakes/r1"), + write(&[meta::REMAKES]) + ); + assert_eq!(needed(&Method::GET, "/admin/config"), read(&[])); + assert_eq!(needed(&Method::GET, "/admin/quarantine/q"), read(&[])); + assert_eq!( + needed(&Method::POST, "/admin/quarantine/q/replay"), + write(&[]) + ); + assert_eq!(needed(&Method::PUT, "/admin/log"), write(&[])); + assert_eq!(needed(&Method::GET, "/debug/firewall"), write(&[])); + } + + #[test] + fn what_a_user_sees_of_a_tree() { + let tree = json!({ + "servers": { "eu": { "a": 1 }, "us": { "b": 2 } }, + "list": [1, 2], + "_continuum": { "users": {} } + }); + let reader = caps(&[("servers/eu", &["read"]), ("list/0", &["read"])]); + assert_eq!( + prune(tree.clone(), &mut Vec::new(), &reader, false), + Some(json!({ "servers": { "eu": { "a": 1 } } })) + ); + let data = caps(&[("", &["read", "write"])]); + assert_eq!( + prune(tree.clone(), &mut Vec::new(), &data, false), + Some(json!({ "servers": { "eu": { "a": 1 }, "us": { "b": 2 } }, "list": [1, 2] })) + ); + assert_eq!( + prune(tree.clone(), &mut Vec::new(), &Access::Everything, false), + Some(tree.clone()) + ); + assert_eq!( + prune(tree, &mut Vec::new(), &caps(&[("x", &["read"])]), false), + Some(json!({})) + ); + assert_eq!( + prune(json!(1), &mut path("y"), &caps(&[("x", &["read"])]), false), + None + ); + } + + #[test] + fn typed_json_keeps_its_tags() { + let reader = caps(&[("m/$x", &["read"]), ("n/k", &["read"])]); + let tree = json!({ + "m": { "$map": { "$x": 1, "$y": 2 } }, + "n": { "k": { "$uint": 5 }, "$z": 1 } + }); + assert_eq!( + prune(tree, &mut Vec::new(), &reader, true), + Some(json!({ "m": { "$map": { "$x": 1 } }, "n": { "k": { "$uint": 5 } } })) + ); + // A tagged scalar on the way is no map. + let tagged = json!({ "t": { "$text": "hello" } }); + assert_eq!( + prune(tagged, &mut Vec::new(), &caps(&[("t/x", &["read"])]), true), + Some(json!({})) + ); + } + + #[test] + fn a_shallow_read_lists_the_way() { + let read = json!({ "type": "map", "size": 3, + "entries": { "a": {}, "b": {}, "_continuum": {} } }); + let reader = caps(&[("a/x", &["read"])]); + assert_eq!( + prune_shallow(read.clone(), &[], &reader), + json!({ "type": "map", "size": 1, "entries": { "a": {} } }) + ); + assert_eq!(prune_shallow(read.clone(), &[], &Access::Everything), read); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index e4ccee6..3e944dc 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,7 @@ //! HTTP API: router, the API description and shared response helpers (the store's //! own are in `store`). +mod access; pub(crate) mod auth; mod changes; mod config; @@ -75,21 +76,25 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> ); } - api.layer(middleware::from_fn_with_state(auth_state, basic_auth)) - // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. - .merge( - // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that - // serves the API under a path of its own too. - SwaggerUi::new("/swagger-ui") - .config(utoipa_swagger_ui::Config::from("../openapi.yaml")), - ) - .route("/openapi.yaml", get(openapi_spec)) - .route("/health", get(health::node_health)) - .route("/ready", get(health::ready)) - // Outermost, so authentication failures get the error body too. - .layer(middleware::from_fn(json_errors)) - .layer(middleware::from_fn(relative_redirects)) - .with_state(state) + // Inside authentication, which finds the user: what the user may do. + api.layer(middleware::from_fn_with_state( + state.clone(), + access::authorize, + )) + .layer(middleware::from_fn_with_state(auth_state, basic_auth)) + // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. + .merge( + // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that + // serves the API under a path of its own too. + SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("../openapi.yaml")), + ) + .route("/openapi.yaml", get(openapi_spec)) + .route("/health", get(health::node_health)) + .route("/ready", get(health::ready)) + // Outermost, so authentication failures get the error body too. + .layer(middleware::from_fn(json_errors)) + .layer(middleware::from_fn(relative_redirects)) + .with_state(state) } /// The HTTP router of a node that only relays (`--relay-only`): its figures and the log diff --git a/src/api/store.rs b/src/api/store.rs index f6cf6fd..1ab3951 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -18,6 +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::auth::AuthenticatedUser; use super::{ApiError, store_status}; use crate::document::meta; @@ -100,15 +101,17 @@ pub(crate) struct DeleteQuery { /// `GET /store`: the whole store (`docs/openapi.yaml`). pub(crate) async fn get_store_root( State(state): State<AppState>, + Extension(access): Extension<Access>, headers: HeaderMap, Query(query): Query<ReadQuery>, ) -> Result<Response, ApiError> { - read(&state, Vec::new(), &headers, &query).await + read(&state, Vec::new(), &headers, &query, access).await } /// `GET /store/{key}`. pub(crate) async fn get_store_entry( State(state): State<AppState>, + Extension(access): Extension<Access>, Path(key): Path<String>, headers: HeaderMap, Query(query): Query<ReadQuery>, @@ -121,21 +124,35 @@ pub(crate) async fn get_store_entry( { return Ok(redirect); } - read(&state, path, &headers, &query).await + read(&state, path, &headers, &query, access).await } -/// A read of `path` as `query` asks (the empty path: the whole store). The root is read -/// while not ready too (`render_read`'s `unready_live`), and is never missing: an empty -/// store is an empty map. +/// `403` where the user may read nothing of `path` (`Access::visible`). +fn readable(access: &Access, path: &[String]) -> Result<(), ApiError> { + if access.visible(path) == Visible::Nothing { + return Err(ApiError( + StatusCode::FORBIDDEN, + Some("no capability to read this (docs/access.md)".to_owned()), + )); + } + Ok(()) +} + +/// A read of `path` as `query` asks (the empty path: the whole store), as much of it as +/// `access` lets its user read (`read_shown`). The root is read while not ready too +/// (`render_read`'s `unready_live`), and is never missing: an empty store is an empty +/// map. async fn read( state: &AppState, path: Vec<String>, headers: &HeaderMap, query: &ReadQuery, + access: Access, ) -> Result<Response, ApiError> { + readable(&access, &path)?; let root = path.is_empty(); if query.shallow { - return shallow(state, path, root, query).await; + return shallow(state, path, root, query, access).await; } let accept = headers.get(ACCEPT).cloned(); let want_conflicts = query.conflicts; @@ -150,7 +167,8 @@ async fn read( doc, &path, accept.as_ref(), - want_conflicts + want_conflicts, + &access )) }, move |result| match result { @@ -194,12 +212,15 @@ impl Read { } } -/// What `path` holds, read to show as `accept` asks; `406` if it can't be. +/// What `path` holds, read to show as `accept` asks; `406` if it can't be. Of a place +/// `access` lets its user read only partly, what it may (`access::prune`), as JSON; +/// `403` for a native form, or if nothing of it is. fn read_shown( doc: &impl crate::document::ReadDoc, path: &[String], accept: Option<&HeaderValue>, conflicts: bool, + access: &Access, ) -> Result<Option<Read>, ApiError> { let failed = |e| store_status(e, "Failed to read store path"); let Some(held) = store::read_held(doc, path).map_err(failed)? else { @@ -224,6 +245,25 @@ fn read_shown( None, ), }; + let (json, conflicts) = match (access.visible(path), shown) { + (Visible::All, _) => (json, conflicts), + (_, Shown::Native(_)) => return Err(StatusCode::FORBIDDEN.into()), + (_, shown) => { + let typed = matches!(shown, Shown::Json(form) if form.typed); + let json = match json { + Some(json) => Some( + prune(json, &mut path.to_vec(), access, typed) + .ok_or(ApiError(StatusCode::FORBIDDEN, None))?, + ), + None => None, + }; + let conflicts = conflicts.map(|mut conflicts: Vec<store::ConflictReport>| { + conflicts.retain(|report| access.visible(&report.path) != Visible::Nothing); + conflicts + }); + (json, conflicts) + } + }; Ok(Some(Read { held, shown, @@ -239,6 +279,7 @@ async fn shallow( path: Vec<String>, unready_live: bool, query: &ReadQuery, + access: Access, ) -> Result<Response, ApiError> { if query.conflicts { return Err(StatusCode::BAD_REQUEST.into()); @@ -248,7 +289,12 @@ async fn shallow( .render_read( unready_live, query.heads, - move |doc| read_at!(doc, at.as_deref(), |doc| store::read_shallow(doc, &path)), + move |doc| { + read_at!(doc, at.as_deref(), |doc| store::read_shallow(doc, &path) + .map( + |value| value.map(|value| prune_shallow(value, &path, &access)) + )) + }, move |result| { let value = result .map_err(|e| store_status(e, "Failed to read store path"))? @@ -319,6 +365,7 @@ const MAX_RETYPED_LISTED: usize = 16; pub(crate) async fn put_store_entry( State(state): State<AppState>, Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, Path(key): Path<String>, Query(query): Query<PutQuery>, RawQuery(raw_query): RawQuery, @@ -329,7 +376,7 @@ pub(crate) async fn put_store_entry( return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } - let path = parse_write_path(&state, &user, &key, "PUT")?; + let path = parse_write_path(&state, &user, &access, &key, "PUT")?; if let Some(redirect) = to_positive(&state, &path, false, None, raw_query.as_deref()).await? { return Ok(redirect); } @@ -342,7 +389,8 @@ pub(crate) async fn put_store_entry( .mutate_doc_for(Some(user.0.clone()), move |doc| { let retyped = store::write_val_path(doc, &path, &body.value, writing) .map_err(|e| store_status(e, "Failed to write store value"))?; - let read = read_shown(&*doc, &path, accept.as_ref(), false)?; + // What was written, shown back to whoever wrote it. + let read = read_shown(&*doc, &path, accept.as_ref(), false, &Access::Everything)?; Ok::<_, ApiError>((retyped, read)) }) .await?; @@ -383,25 +431,33 @@ fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> { Ok(segments) } -/// A path for a write. Protocol data (`_continuum`: remake records, members, forget -/// records, epoch metadata) is what every node acts on, so a write there acts on the -/// whole swarm: only users listed in `--protocol-writers` may, and each such write is +/// A path for a write, if `access` lets the user write there (`403` if not). In a swarm +/// with a user registry, by the user's capabilities (`access.md`); in one without, the +/// swarm's data by anyone, protocol data (`_continuum`: remake records, members, forget +/// records, the users) only by users listed in `--protocol-writers`. Protocol data is +/// what every node acts on, so a write there acts on the whole swarm, and each is /// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never /// writable: the protocol reads it at the snapshot change, so an edit only misleads -/// whoever reads it later. `403` otherwise. +/// whoever reads it later; nor is the swarm ID. fn parse_write_path( state: &AppState, user: &AuthenticatedUser, + access: &Access, key: &str, method: &str, ) -> Result<Vec<String>, StatusCode> { let path = parse_key_path(key)?; - if !meta::is_protocol(&path) { + let protocol = meta::is_protocol(&path); + // `_continuum` itself, epoch metadata and the swarm ID: nobody. + let fixed = protocol && (path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM); + let allowed = match access { + Access::Everything => !protocol || state.protocol_writers.contains(&user.0), + Access::Caps(caps) => caps.allows(crate::access::Right::Write, &path), + }; + if !protocol && allowed { return Ok(path); } - // `_continuum` itself, epoch metadata and the swarm ID: nobody. - let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM; - if fixed || !state.protocol_writers.contains(&user.0) { + if fixed || !allowed { warn!(user = %user.0, method, path = %key, "Refused a write to protocol data"); return Err(StatusCode::FORBIDDEN); } @@ -636,6 +692,7 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<WriteBody, ApiError> pub(crate) async fn post_store_entry( State(state): State<AppState>, Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, Path(key): Path<String>, Query(query): Query<PostQuery>, RawQuery(raw_query): RawQuery, @@ -645,7 +702,7 @@ pub(crate) async fn post_store_entry( if !state.is_ready().await { return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } - let path = parse_write_path(&state, &user, &key, "POST")?; + let path = parse_write_path(&state, &user, &access, &key, "POST")?; if query.increment.is_some() && query.cast.is_some() { return Err(ApiError( StatusCode::BAD_REQUEST, @@ -697,7 +754,7 @@ pub(crate) async fn post_store_entry( .mutate_doc_for(Some(user.0.clone()), move |doc| { let retyped = store::cast_path(doc, &path, to, timestamps) .map_err(|e| store_status(e, "Failed to cast a store value"))?; - let read = read_shown(&*doc, &path, accept.as_ref(), false)?; + let read = read_shown(&*doc, &path, accept.as_ref(), false, &Access::Everything)?; Ok::<_, ApiError>((retyped, read)) }) .await?; @@ -729,11 +786,12 @@ pub(crate) async fn post_store_entry( pub(crate) async fn delete_store_entry( State(state): State<AppState>, Extension(user): Extension<AuthenticatedUser>, + Extension(access): Extension<Access>, Path(key): Path<String>, Query(query): Query<DeleteQuery>, RawQuery(raw_query): RawQuery, ) -> Result<Response, ApiError> { - let path = parse_write_path(&state, &user, &key, "DELETE")?; + let path = parse_write_path(&state, &user, &access, &key, "DELETE")?; tracing::Span::current().record("path", path.join("/").as_str()); @@ -749,10 +807,15 @@ pub(crate) async fn delete_store_entry( return Ok(redirect); } - let prune = query.prune; + // Pruning stops at the highest map the user may write: none above it goes. + let prune = query.prune.then(|| { + (1..=path.len()) + .find(|depth| access.allows(crate::access::Right::Write, &path[..*depth])) + .unwrap_or(path.len()) + }); let ((), heads) = state .mutate_doc_for(Some(user.0.clone()), move |doc| { - store::delete_path(doc, &path, prune) + store::delete_path_pruning(doc, &path, prune) }) .await .map_err(|e| store_status(e, "Failed to delete store value"))?; @@ -832,22 +895,33 @@ async fn render_traced( /// `GET /stream`: follows the whole store, as `GET /store` reads it (events have an /// empty `key`). -pub(crate) async fn stream_store_root(State(state): State<AppState>) -> Result<Response, ApiError> { - stream(state, String::new(), Vec::new()).await +pub(crate) async fn stream_store_root( + State(state): State<AppState>, + Extension(access): Extension<Access>, +) -> Result<Response, ApiError> { + stream(state, String::new(), Vec::new(), access).await } /// `GET /stream/{key}`: follows one value. pub(crate) async fn stream_store_entry( State(state): State<AppState>, + Extension(access): Extension<Access>, Path(key): Path<String>, ) -> Result<Response, ApiError> { let path = parse_key_path(&key)?; - stream(state, key, path).await + stream(state, key, path, access).await } /// Newline-delimited JSON events for the value at `path` (`key` as the client wrote it): -/// the current value, then one whenever the resolved value changes. -async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Response, ApiError> { +/// the current value, then one whenever the resolved value changes; as much of it as +/// `access` lets its user read, as the stream opened (`access::prune`). +async fn stream( + state: AppState, + key: String, + path: Vec<String>, + access: Access, +) -> Result<Response, ApiError> { + readable(&access, &path)?; if !state.is_ready().await { return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } @@ -855,8 +929,13 @@ async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Respo // Subscribe first, so nothing between the initial read and the loop is missed. let mut changes = state.change_events.subscribe(); + let shown = move |(value, conflicted): (Option<JsonValue>, bool), path: &[String]| { + let value = value.and_then(|value| prune(value, &mut path.to_vec(), &access, false)); + (value, conflicted) + }; let initial = render_traced(&state, path.clone()) .await + .map(|read| shown(read, &path)) .map_err(|e| store_status(e, "Failed to read initial stream value"))?; let stream_state = state.clone(); @@ -908,7 +987,7 @@ async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Respo } let (next_value, next_conflicted) = match render_traced(&stream_state, path.clone()).await { - Ok(result) => result, + Ok(result) => shown(result, &path), Err(error) => { error!(error = %format!("{error:#}"), key = %stream_key, "Failed to read updated stream value"); yield Err(IoError::other("Failed to read updated store value")); diff --git a/src/config/mod.rs b/src/config/mod.rs index d1c3953..ac6d1a3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -121,7 +121,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::List, "USER,[USER...]", None, - "HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable.", + "HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake, in a swarm without a user registry. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable. With a registry its capabilities decide instead (docs/access.md); on a genesis node, those of htpasswd's users named here may do everything in the new swarm's registry.", ), option( "bootstrap-users", diff --git a/src/document/store.rs b/src/document/store.rs index db5ecbc..b343a2e 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -1251,6 +1251,16 @@ pub(crate) fn delete_path( path: &[String], prune: bool, ) -> Result<(), StoreError> { + delete_path_pruning(doc, path, prune.then_some(1)) +} + +/// [`delete_path`], pruning maps left empty from the deepest up to the one at depth +/// `prune` (`Some(1)`: a top-level key at most; the root never), if given. +pub(crate) fn delete_path_pruning( + doc: &mut impl Transactable, + path: &[String], + prune: Option<usize>, +) -> Result<(), StoreError> { let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?; // levels[d] = what path[..d] resolves to @@ -1279,9 +1289,9 @@ pub(crate) fn delete_path( Resolved::Other(..) => return Err(StoreError::NotFound), } - if prune { + if let Some(floor) = prune { // Never prune the root (depth 0), nor through a list. - for depth in (1..levels.len()).rev() { + for depth in (floor.max(1)..levels.len()).rev() { let (Resolved::Maps(above), Resolved::Maps(maps)) = (&levels[depth - 1], &levels[depth]) else { |
