aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 01:59:38 +0100
committertv <tv@krebsco.de>2026-11-07 01:59:38 +0100
commit09b4f7daac52b5aa6cebaa9c147a9086b105aac9 (patch)
tree2e18e044e9e1c6b690fe1cfd1f95517a4fcf503c
parent4479bb89595958721a2dfef57dbcb89b255a7ad8 (diff)
access first usersauth
-rw-r--r--README.md1
-rw-r--r--docs/access.md42
-rw-r--r--docs/example.toml6
-rw-r--r--src/access/registry.rs87
-rw-r--r--src/api/auth.rs7
-rw-r--r--src/config/mod.rs22
-rw-r--r--src/main.rs12
-rw-r--r--src/node.rs5
-rw-r--r--src/simtest/mod.rs1
-rw-r--r--src/state/mod.rs2
-rw-r--r--src/swarm/bootstrap.rs24
11 files changed, 208 insertions, 1 deletions
diff --git a/README.md b/README.md
index 87025cf..e06b3b1 100644
--- a/README.md
+++ b/README.md
@@ -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.