diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | a3e936154e07ba3b95157d26ffeae9364f3d5cef (patch) | |
| tree | 771a46e192e9cee20833cc61c9e7e6dad239baba | |
| parent | 02e06975d02bd8f02d811b4dd1d45ecad2374861 (diff) | |
access: logouts apart from users' entries
_continuum/logouts/<name> holds when a user's tokens were last revoked,
written by the user itself or by one who may manage users, so users'
entries can be signed by their managers alone.
Auth plan M7.
| -rw-r--r-- | docs/access.md | 2 | ||||
| -rw-r--r-- | docs/openapi.yaml | 2 | ||||
| -rw-r--r-- | src/access/registry.rs | 25 | ||||
| -rw-r--r-- | src/access/rules.rs | 9 | ||||
| -rw-r--r-- | src/access/token.rs | 9 | ||||
| -rw-r--r-- | src/api/login.rs | 8 | ||||
| -rw-r--r-- | src/document/meta.rs | 2 |
7 files changed, 32 insertions, 25 deletions
diff --git a/docs/access.md b/docs/access.md index 3818b04..682b22b 100644 --- a/docs/access.md +++ b/docs/access.md @@ -96,7 +96,7 @@ curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store then the user logs in again. A refreshed token may say less (`limit`), never more. * **Revoking**: a user disabled or removed is refused at once; all of a user's tokens, by the user (`POST /auth/logout`) or by one who may manage users (`DELETE - /admin/users/{name}/tokens`), which sets `tokensNotBefore` in its entry: tokens issued + /admin/users/{name}/tokens`), which sets `_continuum/logouts/<name>`: tokens issued before aren't taken any more. A token of a node forgotten by the swarm isn't taken either. In a swarm without a registry, a token is taken until it expires. * **What it means to trust**: any member could sign a token for any user, as it could diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 742a013..1954dc0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -836,7 +836,7 @@ paths: post: tags: [Auth] summary: End all of your logins - description: Every token of the user, of every login, no longer taken (`tokensNotBefore`). + description: Every token of the user, of every login, no longer taken (`_continuum/logouts/<name>`). responses: '204': { description: Done. } '409': { description: No user registry; tokens are taken until they expire. } diff --git a/src/access/registry.rs b/src/access/registry.rs index eb7919c..caa263d 100644 --- a/src/access/registry.rs +++ b/src/access/registry.rs @@ -67,7 +67,8 @@ pub(crate) struct User { pub(crate) caps: BTreeMap<String, Capability>, pub(crate) disabled: bool, /// Tokens issued before (Unix ms) are no longer taken (`token::verify`): how all - /// of a user's tokens are revoked at once. + /// of a user's tokens are revoked at once, kept apart from its entry + /// (`logouts_path`), as the user writes it itself. pub(crate) tokens_not_before: u64, /// Its keys (`keys/<id>`, `authorized_keys` lines, `access::sshsig`): what it signs /// values with (`access::envelope`) and logs in with. One that doesn't read is left @@ -100,11 +101,7 @@ impl User { disabled: value .get("disabled") .is_some_and(|disabled| disabled != &JsonValue::Bool(false)), - // A damaged one revokes every token. - tokens_not_before: match value.get("tokensNotBefore") { - None => 0, - Some(at) => at.as_u64().unwrap_or(u64::MAX), - }, + tokens_not_before: 0, keys: value .get("keys") .and_then(JsonValue::as_object) @@ -132,7 +129,21 @@ impl User { /// The entry of user `name`, if it has one. pub(crate) fn user(doc: &impl ReadDoc, name: &str) -> Option<User> { let value = store::read_path(doc, &user_path(name)).ok()??; - value.is_object().then(|| User::from_json(&value)) + let mut user = value.is_object().then(|| User::from_json(&value))?; + // A damaged one revokes every token. + user.tokens_not_before = match store::read_path(doc, &logouts_path(name)) { + Ok(None) => 0, + Ok(Some(at)) => at.as_u64().unwrap_or(u64::MAX), + Err(_) => u64::MAX, + }; + Some(user) +} + +/// Where user `name`'s tokens were last revoked (Unix ms: those issued before aren't +/// taken any more), written by the user itself (`POST /auth/logout`) or by one who may +/// manage users. +pub(crate) fn logouts_path(name: &str) -> Vec<String> { + meta::path(&[meta::LOGOUTS, name]) } /// The users who may manage users: enabled, with `write` on `_continuum/users`. diff --git a/src/access/rules.rs b/src/access/rules.rs index c061893..4df0ba8 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -125,12 +125,7 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result // Written by a root only. (Some(meta::EPOCHS | meta::SWARM), _) => false, // Logging out: a user revokes its own tokens (`POST /auth/logout`). - (Some(meta::USERS), Some(owner)) - if path.get(3).map(String::as_str) == Some("tokensNotBefore") - && change.user == Some(owner) => - { - true - } + (Some(meta::LOGOUTS), Some(owner)) => change.user == Some(owner) || user_may(path), (Some(meta::REMAKES), Some(id)) => match path.get(3).map(String::as_str) { // Its coordinator's; or a record carried into a new epoch whole, by the // node that coordinated it (`remake::switch`): its proposal written with it. @@ -312,7 +307,7 @@ mod tests { &["_continuum/neighbours"] ))); // Logging out: its own tokens only. - let tokens = "_continuum/users/bob/tokensNotBefore"; + let tokens = "_continuum/logouts/bob"; assert!(judged(&doc, "a", Some("bob"), &[tokens]).is_ok()); assert!(refused(judged(&doc, "a", Some("alice"), &[tokens]))); } diff --git a/src/access/token.rs b/src/access/token.rs index bfbce62..3af8672 100644 --- a/src/access/token.rs +++ b/src/access/token.rs @@ -265,19 +265,18 @@ mod tests { }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::UnknownUser)); let mut alice = registry::entry("x", 0, &[(Vec::new(), &[Right::Read][..])]); - alice["tokensNotBefore"] = json!(1_000); change(&mut doc, |tx| { - store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() + store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap(); + store::write_path(tx, ®istry::logouts_path("alice"), &json!(1_000)).unwrap() }); assert!(verify(&doc, &token, "s", 1_500).is_ok()); - alice["tokensNotBefore"] = json!(1_001); change(&mut doc, |tx| { - store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() + store::write_path(tx, ®istry::logouts_path("alice"), &json!(1_001)).unwrap() }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Revoked)); - alice["tokensNotBefore"] = json!(0); alice["disabled"] = json!(true); change(&mut doc, |tx| { + store::write_path(tx, ®istry::logouts_path("alice"), &json!(0)).unwrap(); store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Disabled)); diff --git a/src/api/login.rs b/src/api/login.rs index b84a412..72a4d9d 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -8,7 +8,7 @@ //! * `POST /auth/refresh`, with a token: a new one for the same login, signed by this //! node, optionally narrower still (`limit`, within the token's). //! * `POST /auth/logout`: every token of the user, of every login, no longer taken -//! (`tokensNotBefore` in its registry entry). +//! (`_continuum/logouts/<name>`, `registry::logouts_path`). //! * `GET /auth/whoami`: who the request's user is, how it proved it, what it may do. //! //! Every request may bring a token instead of a password: `Authorization: Bearer ct1…` @@ -258,7 +258,8 @@ pub(crate) async fn revoke_tokens(state: &AppState, name: &str, by: &str) -> Res if !state.is_ready().await { return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } - let mut entry = registry::user_path(name); + let entry = registry::user_path(name); + let logouts = registry::logouts_path(name); // Issued in this very millisecond: revoked too. let after = state.clock.now_millis().as_u64() + 1; state @@ -275,8 +276,7 @@ pub(crate) async fn revoke_tokens(state: &AppState, name: &str, by: &str) -> Res if crate::document::store::read_path(&*doc, &entry)?.is_none() { return Err(ApiError(StatusCode::NOT_FOUND, None)); } - entry.push("tokensNotBefore".to_owned()); - crate::document::store::write_path(doc, &entry, &json!(after))?; + crate::document::store::write_path(doc, &logouts, &json!(after))?; Ok(()) }) .await?; diff --git a/src/document/meta.rs b/src/document/meta.rs index df7741a..691cf40 100644 --- a/src/document/meta.rs +++ b/src/document/meta.rs @@ -20,6 +20,7 @@ //! * `access/policy`: whether every node judges received changes (`access::policy`). //! * `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`). /// The key every protocol path starts with. pub(crate) const ROOT: &str = "_continuum"; @@ -36,6 +37,7 @@ pub(crate) const DISSEMINATION: &str = "dissemination"; pub(crate) const USERS: &str = "users"; pub(crate) const ACCESS: &str = "access"; pub(crate) const BANNED: &str = "banned"; +pub(crate) const LOGOUTS: &str = "logouts"; /// The store path `_continuum/<parts…>`. pub(crate) fn path(parts: &[&str]) -> Vec<String> { |
