aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md50
-rw-r--r--docs/api.md11
-rw-r--r--docs/dashboard.md6
-rw-r--r--docs/example.toml8
-rw-r--r--docs/openapi.yaml4
-rw-r--r--docs/trust.md18
-rw-r--r--src/api/access.rs362
-rw-r--r--src/api/mod.rs35
-rw-r--r--src/api/store.rs139
-rw-r--r--src/config/mod.rs2
-rw-r--r--src/document/store.rs14
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 {