diff options
| -rw-r--r-- | docs/access.md | 14 | ||||
| -rw-r--r-- | docs/openapi.yaml | 4 | ||||
| -rw-r--r-- | src/access/rules.rs | 2 | ||||
| -rw-r--r-- | src/api/access.rs | 86 | ||||
| -rw-r--r-- | src/api/store.rs | 11 | ||||
| -rw-r--r-- | src/document/meta.rs | 4 |
6 files changed, 88 insertions, 33 deletions
diff --git a/docs/access.md b/docs/access.md index c639d0d..a4c5375 100644 --- a/docs/access.md +++ b/docs/access.md @@ -81,9 +81,15 @@ change to it counts on a node as soon as it has reached it. * **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. +* **What a node holds alone** (its log filter, `/admin/log`; its configuration, + `/admin/config`; `/debug`): `read` to look and `write` to act on + `_continuum/local/<node ID>/log`, `…/config`, `…/debug` (`/debug` always `write`). + Nothing is stored there, and nothing may be written there: the paths only name these + for capabilities. `_continuum/local/<node ID>` is everything on one node, + `_continuum/local/*/log` the log filter on every node. +* **Everything else an operator does** (`/admin/document`, `/admin/changes`, + `/admin/epochs`, `/admin/quarantine`): `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`). @@ -248,7 +254,7 @@ stays as it is, wherever it comes from later (a joining node catching up, say). the dashboard right); removed also by one who forgets the member in the same change (`DELETE /peers/{pk}`, with `_continuum/forgotten`). Its votes by that member only. * Epochs and the swarm ID (`_continuum/epochs`, `_continuum/swarm`) by an epoch's first - change only (the genesis change, a remake's snapshot). + change only (the genesis change, a remake's snapshot). `_continuum/local` by none. * A remake's record and its proposal by whichever member proposes, where there are none; its decision by the coordinator its proposal names, or a user who may write there (an operator aborting an expired remake through any member, `DELETE diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8e6a688..0fad136 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -206,7 +206,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - 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); a signed value not by this user, or whose signature doesn't check (the message says why). + 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, the swarm ID or what a node holds alone (`_continuum/epochs`, `_continuum/swarm`, `_continuum/local`, never writable); a signed value not by this user, or whose signature doesn't check (the message says why). '409': description: The path crosses an existing non-map value, or with `retype=false` the write would change a type (the message says where); a value not signed reaching one its user signs under a strict policy; a signed value where values aren't signed, or replacing another than the one there (read again); a value left not sealed as the swarm declares (`docs/access.md`, Values sealed for their readers). '415': @@ -330,7 +330,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - 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). + 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, the swarm ID or what a node holds alone (`_continuum/epochs`, `_continuum/swarm`, `_continuum/local`, never writable). '404': description: No value at this path. '409': diff --git a/src/access/rules.rs b/src/access/rules.rs index d8162a6..d120961 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -265,6 +265,8 @@ pub(crate) fn judge_reading( } // Written by a root only. (Some(meta::EPOCHS | meta::SWARM), _) => false, + // Never stored: named for capabilities only (`api::access::needed`). + (Some(meta::LOCAL), _) => false, // Logging out: a user revokes its own tokens (`POST /auth/logout`); one who // manages users, anyone's (`DELETE /admin/users/{name}/tokens`). (Some(meta::LOGOUTS), Some(owner)) => { diff --git a/src/api/access.rs b/src/api/access.rs index 29d796a..8737ef8 100644 --- a/src/api/access.rs +++ b/src/api/access.rs @@ -247,10 +247,12 @@ enum Need { /// `_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 { +fn needed(method: &Method, path: &str, node: &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}/")); + // What this node holds alone: `_continuum/local/<node>/<name>`, never stored. + let local = |right, name: &str| protocol(right, &[meta::LOCAL, node, name]); if under("/store") || under("/stream") { Need::Store } else if under("/auth") { @@ -273,7 +275,12 @@ fn needed(method: &Method, path: &str) -> Need { } else { protocol(Right::Write, &[meta::REMAKES]) } - } else if looking && !under("/debug") { + } else if under("/admin/log") || under("/admin/config") { + let name = if under("/admin/log") { "log" } else { "config" }; + local(if looking { Right::Read } else { Right::Write }, name) + } else if under("/debug") { + local(Right::Write, "debug") + } else if looking { protocol(Right::Read, &[]) } else { protocol(Right::Write, &[]) @@ -318,7 +325,7 @@ pub(crate) async fn authorize( } } }; - let allowed = match needed(request.method(), request.uri().path()) { + let allowed = match needed(request.method(), request.uri().path(), state.local_id()) { Need::Store | Need::Auth => true, Need::Observe => match &access { Access::Everything => true, @@ -377,51 +384,82 @@ mod tests { 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::POST, "/auth/login"), Need::Auth); - assert_eq!(needed(&Method::GET, "/auth/whoami"), Need::Auth); - 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, "/store", "n1"), Need::Store); + assert_eq!(needed(&Method::POST, "/auth/login", "n1"), Need::Auth); + assert_eq!(needed(&Method::GET, "/auth/whoami", "n1"), Need::Auth); + assert_eq!(needed(&Method::PUT, "/store/a/b", "n1"), Need::Store); + assert_eq!(needed(&Method::GET, "/stream/a", "n1"), Need::Store); + assert_eq!(needed(&Method::GET, "/storehouse", "n1"), read(&[])); + assert_eq!(needed(&Method::GET, "/peers", "n1"), Need::Observe); + assert_eq!(needed(&Method::GET, "/admin/stats", "n1"), Need::Observe); assert_eq!( - needed(&Method::GET, "/admin/dashboard/state"), + needed(&Method::GET, "/admin/dashboard/state", "n1"), Need::Observe ); assert_eq!( - needed(&Method::POST, "/admin/dashboard/refresh"), + needed(&Method::POST, "/admin/dashboard/refresh", "n1"), Need::Observe ); assert_eq!( - needed(&Method::DELETE, "/peers/continuum:00"), + needed(&Method::DELETE, "/peers/continuum:00", "n1"), write(&[meta::FORGOTTEN]) ); assert_eq!( - needed(&Method::GET, "/admin/remakes/r1"), + needed(&Method::GET, "/admin/remakes/r1", "n1"), read(&[meta::REMAKES]) ); assert_eq!( - needed(&Method::POST, "/admin/remakes"), + needed(&Method::POST, "/admin/remakes", "n1"), write(&[meta::REMAKES]) ); assert_eq!( - needed(&Method::DELETE, "/admin/remakes/r1"), + needed(&Method::DELETE, "/admin/remakes/r1", "n1"), write(&[meta::REMAKES]) ); - assert_eq!(needed(&Method::GET, "/admin/config"), read(&[])); assert_eq!( - needed(&Method::GET, "/admin/users/alice"), + needed(&Method::GET, "/admin/config", "n1"), + read(&[meta::LOCAL, "n1", "config"]) + ); + assert_eq!( + needed(&Method::GET, "/admin/users/alice", "n1"), 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"), + needed(&Method::POST, "/admin/users", "n1"), + write(&[meta::USERS]) + ); + assert_eq!(needed(&Method::GET, "/admin/quarantine/q", "n1"), read(&[])); + assert_eq!( + needed(&Method::POST, "/admin/quarantine/q/replay", "n1"), write(&[]) ); - assert_eq!(needed(&Method::PUT, "/admin/log"), write(&[])); - assert_eq!(needed(&Method::GET, "/debug/firewall"), write(&[])); + assert_eq!( + needed(&Method::GET, "/admin/log", "n1"), + read(&[meta::LOCAL, "n1", "log"]) + ); + assert_eq!( + needed(&Method::PUT, "/admin/log", "n1"), + write(&[meta::LOCAL, "n1", "log"]) + ); + assert_eq!( + needed(&Method::GET, "/debug/firewall", "n1"), + write(&[meta::LOCAL, "n1", "debug"]) + ); + // Every node's log filter, by one capability. + let logs = Caps::new(vec![Capability { + path: path("_continuum/local/*/log"), + rights: ["write".to_owned()].into(), + by: String::new(), + since: 0, + }]); + let Need::Protocol(right, at) = needed(&Method::PUT, "/admin/log", "n2") else { + unreachable!() + }; + assert!(logs.allows(right, &at)); + let Need::Protocol(right, at) = needed(&Method::GET, "/admin/config", "n2") else { + unreachable!() + }; + assert!(!logs.allows(right, &at)); } #[test] diff --git a/src/api/store.rs b/src/api/store.rs index e43d129..00ced48 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -662,7 +662,8 @@ fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> { /// 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; nor is the swarm ID. A path inside a list or text is +/// whoever reads it later; nor is the swarm ID, nor `_continuum/local`, which names +/// what a node holds alone for capabilities and is never stored. A path inside a list or text is /// judged where the rules judge the write (`inspect::placed`): at the list. async fn parse_write_path( state: &AppState, @@ -674,8 +675,12 @@ async fn parse_write_path( let path = parse_key_path(key)?; let placed = crate::document::inspect::placed(&state.doc.read().await.doc, &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); + // `_continuum` itself, epoch metadata, the swarm ID and what is never stored: nobody. + let fixed = protocol + && (path.len() < 2 + || path[1] == meta::EPOCHS + || path[1] == meta::SWARM + || path[1] == meta::LOCAL); let allowed = match access { Access::Everything => !protocol || state.protocol_writers.contains(&user.0), Access::Caps(caps) => caps.allows(crate::access::Right::Write, &placed), diff --git a/src/document/meta.rs b/src/document/meta.rs index 691cf40..e040ba0 100644 --- a/src/document/meta.rs +++ b/src/document/meta.rs @@ -21,6 +21,9 @@ //! * `access/userSigned`: the paths whose values their users sign (`access::envelope`). //! * `banned/<pk>`: banned nodes (`access::bans`). //! * `logouts/<name>`: when a user's tokens were last revoked (`access::registry`). +//! * `local/<pk>/<name>`: never stored, and never writable: what a node holds alone +//! (its log filter, its configuration, its debug endpoints), named for capabilities +//! (`api::access::needed`). /// The key every protocol path starts with. pub(crate) const ROOT: &str = "_continuum"; @@ -38,6 +41,7 @@ pub(crate) const USERS: &str = "users"; pub(crate) const ACCESS: &str = "access"; pub(crate) const BANNED: &str = "banned"; pub(crate) const LOGOUTS: &str = "logouts"; +pub(crate) const LOCAL: &str = "local"; /// The store path `_continuum/<parts…>`. pub(crate) fn path(parts: &[&str]) -> Vec<String> { |
