aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/rules.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 16:15:39 +0000
committertv <tv@krebsco.de>2026-11-08 17:36:11 +0100
commit63c2272a4feb890277ed1362b48a3524d0b07165 (patch)
treed69b836320a72f70ba15b10aec6c32b578f1f9f8 /src/access/rules.rs
parenta7c70346b448f493991df1e035beaa4bde113b4f (diff)
users: a user can't remove its own last key
A user managing its own keys (1168) could remove the last, and then neither sign nor log in by key. It can't now: DELETE /auth/keys/{id} of the last answers 409, the rules take a user's change of its own keys only with one left after it, and under a strict policy its own entry signed only with one key at least. One who may manage users still may.
Diffstat (limited to 'src/access/rules.rs')
-rw-r--r--src/access/rules.rs103
1 files changed, 79 insertions, 24 deletions
diff --git a/src/access/rules.rs b/src/access/rules.rs
index 450addb..4e4e6e9 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -193,12 +193,13 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result
|| user_may(path)
|| user_may(&meta::path(&[meta::USERS]))
}
- // A user's own keys (`/auth/keys`), whatever else it may do.
+ // A user's own keys (`/auth/keys`), whatever else it may do, one left at
+ // least.
(Some(meta::USERS), Some(owner))
if path.get(3).map(String::as_str) == Some("keys")
&& change.user == Some(owner) =>
{
- true
+ keys_left(&at, owner, change.ops)
}
// A node forgotten while alive cancels its own record (`swarm::peer::forget`).
(Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path),
@@ -479,6 +480,30 @@ fn aborts_expired(at: &At<'_>, id: &str, change: &Change<'_>) -> bool {
change.time >= settle_until.saturating_add(grace.saturating_mul(2))
}
+/// Whether `owner` has a key left after `ops`, its keys at `at` as they change them.
+fn keys_left(at: &At<'_>, owner: &str, ops: &[Op]) -> bool {
+ let keys = meta::path(&[meta::USERS, owner, "keys"]);
+ let mut left: BTreeSet<String> = store::keys_at(at, &keys)
+ .ok()
+ .flatten()
+ .unwrap_or_default()
+ .into_iter()
+ .collect();
+ for op in ops.iter().filter(|op| op.path.starts_with(&keys)) {
+ match (op.path.get(keys.len()), &op.action) {
+ (None, _) => left.clear(),
+ (Some(id), Action::Delete) => {
+ left.remove(id);
+ }
+ (Some(id), _) if op.path.len() == keys.len() + 1 => {
+ left.insert(id.clone());
+ }
+ _ => {}
+ }
+ }
+ !left.is_empty()
+}
+
/// The coordinator remake `id`'s proposal names, at `at`.
fn coordinator(at: &At<'_>, id: &str) -> Option<String> {
let proposal = store::read_path(at, &meta::path(&[meta::REMAKES, id, "proposal"]))
@@ -795,38 +820,68 @@ mod tests {
assert!(decide(Some("carol"), "committed", 0).is_ok());
}
- /// A user's own keys: its own to change, whatever else it may do; another's, or the
- /// rest of its own entry, not.
+ /// A user's own keys: its own to change, whatever else it may do, one left at least;
+ /// another's, or the rest of its own entry, not.
#[test]
fn users_change_their_own_keys() {
- let doc = swarm(true);
- assert!(
- judged(
- &doc,
- "a",
- Some("bob"),
- &["_continuum/users/bob/keys/laptop"]
+ let mut doc = swarm(true);
+ let judged_ops = |doc: &Document, user: Option<&str>, ops: Vec<Op>| {
+ let deps = doc.get_heads();
+ let touched: BTreeSet<Path> = ops.iter().map(|op| op.path.clone()).collect();
+ judge(
+ doc,
+ Epoch::GENESIS,
+ &Change {
+ hash: ChangeHash([9; 32]),
+ deps: &deps,
+ seen: &deps,
+ committer: Some("a"),
+ user,
+ paths: &touched,
+ ops: &ops,
+ time: 0,
+ },
)
- .is_ok()
- );
- assert!(judged(&doc, "a", Some("bob"), &["_continuum/users/bob/keys"]).is_ok());
- assert!(refused(judged(
+ };
+ let op = |at: &str, action: Action| Op {
+ path: path(at),
+ action,
+ };
+ let put = |at: &str| op(at, Action::PutString("ssh-ed25519 AAAA".to_owned()));
+ let bobs = "_continuum/users/bob/keys/laptop";
+ assert!(judged_ops(&doc, Some("bob"), vec![put(bobs)]).is_ok());
+ assert!(refused(judged_ops(
&doc,
- "a",
Some("bob"),
- &["_continuum/users/alice/keys/laptop"]
+ vec![put("_continuum/users/alice/keys/laptop")]
)));
- assert!(refused(judged(
+ assert!(refused(judged_ops(
&doc,
- "a",
Some("bob"),
- &["_continuum/users/bob/caps"]
+ vec![op("_continuum/users/bob/caps", Action::MakeMap)]
)));
- assert!(refused(judged(
+ assert!(refused(judged_ops(&doc, None, vec![put(bobs)])));
+ // With one key: another added, one of two removed; not the last.
+ write(&mut doc, bobs, json!("ssh-ed25519 AAAA"));
+ let phone = "_continuum/users/bob/keys/phone";
+ assert!(judged_ops(&doc, Some("bob"), vec![put(phone)]).is_ok());
+ assert!(refused(judged_ops(
&doc,
- "a",
- None,
- &["_continuum/users/bob/keys/laptop"]
+ Some("bob"),
+ vec![op(bobs, Action::Delete)]
+ )));
+ assert!(
+ judged_ops(
+ &doc,
+ Some("bob"),
+ vec![put(phone), op(bobs, Action::Delete)]
+ )
+ .is_ok()
+ );
+ assert!(refused(judged_ops(
+ &doc,
+ Some("bob"),
+ vec![op("_continuum/users/bob/keys", Action::Delete)]
)));
}