diff options
| author | tv <tv@krebsco.de> | 2026-11-07 01:59:38 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 01:59:38 +0100 |
| commit | 09b4f7daac52b5aa6cebaa9c147a9086b105aac9 (patch) | |
| tree | 2e18e044e9e1c6b690fe1cfd1f95517a4fcf503c | |
| parent | 4479bb89595958721a2dfef57dbcb89b255a7ad8 (diff) | |
access first usersauth
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | docs/access.md | 42 | ||||
| -rw-r--r-- | docs/example.toml | 6 | ||||
| -rw-r--r-- | src/access/registry.rs | 87 | ||||
| -rw-r--r-- | src/api/auth.rs | 7 | ||||
| -rw-r--r-- | src/config/mod.rs | 22 | ||||
| -rw-r--r-- | src/main.rs | 12 | ||||
| -rw-r--r-- | src/node.rs | 5 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 1 | ||||
| -rw-r--r-- | src/state/mod.rs | 2 | ||||
| -rw-r--r-- | src/swarm/bootstrap.rs | 24 |
11 files changed, 208 insertions, 1 deletions
@@ -90,6 +90,7 @@ curl -u alice:secret localhost:3000/peers # who is out there alert on. * [`docs/remakes.md`](docs/remakes.md): compacting history and other moves to a new document. * [`docs/trust.md`](docs/trust.md): who can do what. +* [`docs/access.md`](docs/access.md): users, and what each may do. * [`docs/topology.md`](docs/topology.md): who syncs with whom, and how a change passes on: the choices, which combine well, and what they cost. * [`docs/measurements.md`](docs/measurements.md): `/admin/stats` and its Prometheus metrics. diff --git a/docs/access.md b/docs/access.md new file mode 100644 index 0000000..8cb1ef9 --- /dev/null +++ b/docs/access.md @@ -0,0 +1,42 @@ +# Users and what they may do + +Who the users are, and what each may do, is the swarm's: a registry in the document, +the same on every node. How a user proves who it is stays each node's: its +`--htpasswd`, or a reverse proxy naming the user (`--user-header`, [`trust.md`](trust.md)). + +## The registry + +`_continuum/users/<name>`, one entry per user. It is protocol data: every member holds +it, a remake's snapshot carries it into the next epoch, and it holds no secrets (no +password hashes: every member reads the document). + +```json +{ "since": 1762400000000, "by": "continuum:3f…", + "caps": { "8c41e2…": { "path": [], "rights": ["read", "write"], + "by": "continuum:3f…", "since": 1762400000000 } }, + "disabled": false } +``` + +* **Names** are what `--htpasswd` and the proxy give: 1 to 64 of `A-Z a-z 0-9 . _ @ -`. +* **Capabilities** (`caps`): each a path, as segments as the store's API takes them, + and the rights it gives there and below: `read`, `write`. A capability covers its + path segment by segment (`servers/eu` covers `servers/eu/0`, not `servers/europe`). + One outside protocol data never covers protocol data: `[]` is all of the swarm's + data, `["_continuum"]` protocol data, managing users included (`_continuum/users`). + They are kept by a random ID, so two capabilities granted at once both hold, and + revoking one removes it alone. Each names who granted it (`by`: a user as + `user:<name>`, or the node that recorded it) and when (`since`). +* **`disabled`**: the entry stays (who wrote what stays readable), and gives nothing. + An entry that doesn't read as above gives less, never more: a capability that + doesn't read is left out, and anything but `false` disables. + +## A new swarm + +Its genesis node records the first users, with the swarm ID, in its first change: + +* those of `--bootstrap-users`, each with everything (`[]` and `["_continuum"]`); +* unset: the users of its `--htpasswd` with `[]`, and those of them, or of the + proxy's, in `--protocol-writers` with `["_continuum"]` too. + +A swarm nobody could manage doesn't start: with none of them with `["_continuum"]`, +`--genesis` is refused, naming both options. diff --git a/docs/example.toml b/docs/example.toml index a868327..26be818 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -85,6 +85,12 @@ htpasswd = "/etc/continuum/htpasswd" # writable. # protocol-writers = ["alice"] +# With genesis: the new swarm's first users, each may do everything, protocol data and +# managing users included (docs/access.md). Unset, the users of htpasswd may read and +# write the swarm's data, and those also in protocol-writers everything. A swarm nobody +# could manage doesn't start: set this, or protocol-writers, for one of htpasswd's users. +# bootstrap-users = ["alice"] + # How log lines look: text (timestamped), journal (for systemd's journal: no timestamp or # colours, the level as each line's priority, for journalctl -p), or auto: journal when # stdout goes to the journal, else text. diff --git a/src/access/registry.rs b/src/access/registry.rs index c4ad0e6..e863ce5 100644 --- a/src/access/registry.rs +++ b/src/access/registry.rs @@ -133,6 +133,53 @@ pub(crate) fn capability(by: &str, since: u64, path: &[String], rights: &[Right] }) } +/// The first users of a new swarm, as its genesis node records them (`first_users`): +/// each name, and whether it may do everything (protocol data and managing users +/// included), not only read and write the swarm's data. +pub(crate) type FirstUsers = Vec<(String, bool)>; + +/// The first users of a new swarm, from the genesis node's options: those of +/// `bootstrap_users`, each may do everything; unset, the users of its htpasswd file +/// (`htpasswd`) may read and write the swarm's data, and those of them, or of the proxy's +/// (`--user-header`), in `protocol_writers` everything. Refused if nobody could manage +/// the swarm: a registry nobody may change would lock everyone out for good. +pub(crate) fn first_users( + bootstrap_users: &[String], + htpasswd: &[String], + protocol_writers: &[String], +) -> anyhow::Result<FirstUsers> { + let mut users: BTreeMap<String, bool> = BTreeMap::new(); + if bootstrap_users.is_empty() { + for name in htpasswd.iter().filter(|name| valid_name(name)) { + users.insert(name.clone(), false); + } + for name in protocol_writers.iter().filter(|name| valid_name(name)) { + users.insert(name.clone(), true); + } + } else { + for name in bootstrap_users { + users.insert(name.clone(), true); + } + } + if !users.values().any(|admin| *admin) { + anyhow::bail!( + "genesis: nobody could manage the new swarm's users; name one with bootstrap-users, or put one of htpasswd's users in protocol-writers (docs/access.md)" + ); + } + Ok(users.into_iter().collect()) +} + +/// The capabilities of a first user (`first_users`): the swarm's data, and for one who +/// may do everything protocol data too. +pub(crate) fn first_caps(admin: bool) -> Vec<(Vec<String>, &'static [Right])> { + const BOTH: &[Right] = &[Right::Read, Right::Write]; + let mut caps = vec![(Vec::new(), BOTH)]; + if admin { + caps.push((meta::path(&[]), BOTH)); + } + caps +} + /// A random ID for a capability (8 bytes, hex): concurrent grants don't collide. pub(crate) fn new_id() -> String { let mut bytes = [0u8; 8]; @@ -214,6 +261,46 @@ mod tests { } #[test] + fn the_first_users() { + let names = |list: &[&str]| list.iter().map(|n| (*n).to_owned()).collect::<Vec<_>>(); + assert_eq!( + first_users(&[], &names(&["alice", "bob"]), &names(&["alice"])).unwrap(), + vec![("alice".to_owned(), true), ("bob".to_owned(), false)] + ); + // A protocol writer the proxy names, not in htpasswd. + assert_eq!( + first_users(&[], &names(&["bob"]), &names(&["carol"])).unwrap(), + vec![("bob".to_owned(), false), ("carol".to_owned(), true)] + ); + assert_eq!( + first_users(&names(&["root"]), &names(&["alice"]), &names(&["alice"])).unwrap(), + vec![("root".to_owned(), true)] + ); + assert!(first_users(&[], &names(&["alice"]), &[]).is_err()); + assert!(first_users(&[], &[], &[]).is_err()); + let admin = Caps::new( + first_caps(true) + .into_iter() + .map(|(path, rights)| { + serde_json::from_value(capability("x", 0, &path, rights)).unwrap() + }) + .collect(), + ); + assert!(admin.allows(Right::Write, &path("_continuum/users/x"))); + assert!(admin.allows(Right::Write, &path("accounts"))); + let user = Caps::new( + first_caps(false) + .into_iter() + .map(|(path, rights)| { + serde_json::from_value(capability("x", 0, &path, rights)).unwrap() + }) + .collect(), + ); + assert!(user.allows(Right::Write, &path("accounts"))); + assert!(!user.allows(Right::Read, &path("_continuum"))); + } + + #[test] fn concurrent_grants_both_hold() { let mut ours = Document::new(); let entry = entry("x", 0, &[]); diff --git a/src/api/auth.rs b/src/api/auth.rs index 400feed..f85b64e 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -52,6 +52,13 @@ fn load_users(path: &str) -> anyhow::Result<HashMap<String, String>> { Ok(users) } +/// The user names of the htpasswd file at `path` (`load_users`), sorted. +pub(crate) fn htpasswd_names(path: &str) -> anyhow::Result<Vec<String>> { + let mut names: Vec<String> = load_users(path)?.into_keys().collect(); + names.sort(); + Ok(names) +} + /// Who may use the HTTP API: the users of an htpasswd file (basic authentication), /// and the user a reverse proxy in front names (`--user-header`), if it is one of /// `--trusted-proxies`. diff --git a/src/config/mod.rs b/src/config/mod.rs index db700ef..d1c3953 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -124,6 +124,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "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.", ), option( + "bootstrap-users", + Kind::List, + "USER,[USER...]", + None, + "With genesis: the new swarm's first users, each may do everything, protocol data and managing users included (docs/access.md). Unset, the users of htpasswd may read and write the swarm's data, and those also in protocol-writers everything. A swarm nobody could manage doesn't start: set this, or protocol-writers, for one of htpasswd's users.", + ), + option( "log-format", Kind::Text, "auto|text|journal", @@ -535,6 +542,8 @@ pub(crate) struct Settings { #[serde(default)] pub(crate) protocol_writers: Vec<String>, #[serde(default)] + pub(crate) bootstrap_users: Vec<String>, + #[serde(default)] pub(crate) debug_api: bool, #[serde(default = "default_log_format")] pub(crate) log_format: String, @@ -751,6 +760,18 @@ impl Settings { if self.remake.is_some() && !self.genesis { bail!("remake makes a new swarm's document; it needs genesis"); } + if !self.bootstrap_users.is_empty() && !self.genesis { + bail!( + "bootstrap-users names the first users of a new swarm; it needs genesis (drop it once the swarm exists)" + ); + } + if let Some(bad) = self + .bootstrap_users + .iter() + .find(|name| !crate::access::registry::valid_name(name)) + { + bail!("bootstrap-users: {bad:?} isn't a user name (1 to 64 of A-Z a-z 0-9 . _ @ -)"); + } if self.genesis && self.recover_from.is_some() { bail!("recover-from starts the swarm from a backup; it can't be combined with genesis"); } @@ -899,6 +920,7 @@ impl Settings { ("swarm-key-file", self.swarm_key_file.is_some()), ("swarm-key-create", self.swarm_key_create), ("protocol-writers", !self.protocol_writers.is_empty()), + ("bootstrap-users", !self.bootstrap_users.is_empty()), ("alias", self.alias.is_some()), ("dashboard-url", self.dashboard_url.is_some()), ("debug-api", self.debug_api), diff --git a/src/main.rs b/src/main.rs index 114f595..b3eb438 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,6 +97,17 @@ async fn main() -> Result<()> { // A broken htpasswd will abort startup. let auth_state = AuthState::from_settings(&args)?.context("htpasswd or user-header is required")?; + // A new swarm's first users, before the minutes of startup too: one nobody could + // manage doesn't start. + let first_users = if args.genesis { + let htpasswd = match args.htpasswd.as_deref() { + Some(path) => api::auth::htpasswd_names(path)?, + None => Vec::new(), + }; + access::registry::first_users(&args.bootstrap_users, &htpasswd, &args.protocol_writers)? + } else { + Vec::new() + }; info!( bind_addr = ?args.bind_addr.iter().map(ToString::to_string).collect::<Vec<_>>(), @@ -232,6 +243,7 @@ async fn main() -> Result<()> { blob_chunk_bytes: args.blob_chunk as u32, swarm_key, protocol_writers: args.protocol_writers.iter().cloned().collect(), + first_users, files, state_file, document_file, diff --git a/src/node.rs b/src/node.rs index 28ce095..c172fb8 100644 --- a/src/node.rs +++ b/src/node.rs @@ -60,6 +60,10 @@ pub(crate) struct NodeConfig { pub(crate) blob_chunk_bytes: u32, pub(crate) swarm_key: Option<Arc<SwarmKey>>, pub(crate) protocol_writers: HashSet<String>, + /// On a genesis node: the swarm's first users, which it records + /// (`access::registry::first_users`); empty elsewhere, and where none were named + /// (the simulations: no registry, as in a swarm from before). + pub(crate) first_users: crate::access::registry::FirstUsers, pub(crate) files: Arc<Files>, pub(crate) state_file: Option<Arc<StateFile>>, pub(crate) document_file: Option<Arc<DocumentFile>>, @@ -139,6 +143,7 @@ impl Node { blobs: Arc::new(swarm::blob::Blobs::new(config.blob_chunk_bytes)), links: Default::default(), protocol_writers: Arc::new(config.protocol_writers), + first_users: Arc::new(config.first_users), state_file: config.state_file, document_file: config.document_file, files: config.files, diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index b609d8a..5a202b5 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -212,6 +212,7 @@ impl Sim { blob_chunk_bytes: 64 * 1024, swarm_key: None, protocol_writers: HashSet::new(), + first_users: Vec::new(), files: Arc::new(Files::default()), state_file: None, document_file: None, diff --git a/src/state/mod.rs b/src/state/mod.rs index 3dfc6b1..c420361 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -143,6 +143,8 @@ pub(crate) struct AppState { /// HTTP API users who may write protocol data through the store API /// (`--protocol-writers`). pub(crate) protocol_writers: Arc<std::collections::HashSet<String>>, + /// The swarm's first users, recorded by a genesis node (`NodeConfig::first_users`). + pub(crate) first_users: Arc<crate::access::registry::FirstUsers>, /// This node's own files, and how much it read and wrote them. pub(crate) files: Arc<crate::files::Files>, /// This node's document on disk (`--document-file`), if configured. diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs index 76e056d..a7266c7 100644 --- a/src/swarm/bootstrap.rs +++ b/src/swarm/bootstrap.rs @@ -91,8 +91,28 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) // Plain names (kebab-case): serializing them can't fail. let topology = serde_json::to_value(state.configured_ways.0).unwrap_or_default(); let dissemination = serde_json::to_value(state.configured_ways.1).unwrap_or_default(); + // The first users, granted by this node: with the rest, so that no node sees the + // swarm without its registry (none named: none, as in a swarm from before). + let (by, since) = ( + state.local_id().to_owned(), + state.clock.now_millis().as_u64(), + ); + let users: Vec<(Vec<String>, JsonValue)> = state + .first_users + .iter() + .map(|(name, admin)| { + let caps = crate::access::registry::first_caps(*admin); + ( + crate::access::registry::user_path(name), + crate::access::registry::entry(&by, since, &caps), + ) + }) + .collect(); state .mutate_doc(move |doc| { + for (path, entry) in &users { + store::write_path(doc, path, entry)?; + } store::write_path(doc, &swarm_id_path(), &recorded)?; store::write_path( doc, @@ -107,7 +127,9 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) ) }) .await - .context("Recording the swarm ID, its neighbours, its topology and dissemination")?; + .context( + "Recording the swarm ID, its neighbours, its topology and dissemination, its first users", + )?; state.initial_sync_done.store(true, Ordering::Release); state.mark_ready(swarm_id).await?; // Remembered members, if any, to find those still running. |
