aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 22:23:52 +0000
committertv <tv@krebsco.de>2026-11-11 22:21:10 +0100
commita0c8fcb2693cc017d78e39a6e623d1c53e493a84 (patch)
tree4abf280e776df1b52796f5bf3f356af66509e89a
parentb24abedb2465a62a64e3fb2b02445cfc1de9a778 (diff)
access: a token with a limit changes no credentials
A token's limit narrows every endpoint but those under /auth, which any registered user may call. Among them, PUT and DELETE /auth/keys/{id} change the keys the user logs in with, and POST /auth/logout ends every token of the user. With the token a dashboard hands to another member's (the member list, read, five minutes), its holder could add a key of its own and log in by it as the user, with everything the user may do. These now take a password or a token without a limit (Need::Credentials, refused where Caps::limited). A narrowed token still refreshes within its limit, says who it is, lists the user's keys and ends its own session (DELETE /auth/session): a dashboard opened from another member's logs out with it.
-rw-r--r--docs/access.md8
-rw-r--r--src/access/mod.rs5
-rw-r--r--src/api/access.rs24
3 files changed, 33 insertions, 4 deletions
diff --git a/docs/access.md b/docs/access.md
index 571034e..5a1e04b 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -35,7 +35,8 @@ password hashes: every member reads the document).
`authorized_keys` line has it (`"laptop": "ssh-ed25519 AAAA… alice@laptop"`; Ed25519
only; the comment, 256 bytes at most, is kept and checks nothing): what it signs values with (Values signed by their users, below) and logs in with. A user
manages its own, whatever else it may do: `GET /auth/keys`, `PUT /auth/keys/<name>`
- with the line, `DELETE /auth/keys/<name>`; under a strict policy, by signing its
+ with the line, `DELETE /auth/keys/<name>` (not with a token that has a `limit`); under a
+ strict policy, by signing its
entry with only its keys changed (`continuum-signed key-add laptop
~/.ssh/id_ed25519.pub`, `key-remove laptop`), with a key it holds already. Every
node takes that from the user itself, as from one who may manage users. Its last key
@@ -124,10 +125,11 @@ curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store
refreshes it past its end. A dashboard opening another member's hands it one reading
the member list only, for five minutes ([`dashboard.md`](dashboard.md)).
* **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
+ by the user (`POST /auth/logout`, not with a token that has a `limit`) or by one who may manage users (`DELETE
/admin/users/{name}/tokens`), which sets `_continuum/logouts/<name>`: tokens issued
before aren't taken any more. One session's (`DELETE /auth/session`, the dashboard's
- "log out"): `_continuum/sessions/<name>/<session>`, until when the session would have
+ "log out", with any token of the session, a narrowed one too, as a dashboard opened
+ from another member's holds): `_continuum/sessions/<name>/<session>`, until when the session would have
lasted, written by the user or one who manages users; a remake's snapshot leaves it
out once that has passed. Without a registry, the node ending it refuses the
session's tokens itself, until it restarts; others take them until they expire.
diff --git a/src/access/mod.rs b/src/access/mod.rs
index e04be02..ad50c72 100644
--- a/src/access/mod.rs
+++ b/src/access/mod.rs
@@ -192,6 +192,11 @@ impl Caps {
.is_none_or(|limit| limit.iter().any(|cap| cap.reaches(right, path)))
}
+ /// Whether a token's limit narrows them (`within`).
+ pub(crate) fn limited(&self) -> bool {
+ self.limit.is_some()
+ }
+
pub(crate) fn is_empty(&self) -> bool {
self.caps.is_empty() || self.limit.as_ref().is_some_and(Vec::is_empty)
}
diff --git a/src/api/access.rs b/src/api/access.rs
index 6d26139..6977a0a 100644
--- a/src/api/access.rs
+++ b/src/api/access.rs
@@ -252,8 +252,13 @@ enum Need {
Store,
/// Looking at this node (its figures, peers, dashboard): any capability.
Observe,
- /// Logging in, refreshing, asking who one is: any registered user.
+ /// Logging in, refreshing, asking who one is, ending one's session: any registered
+ /// user.
Auth,
+ /// Changing what one logs in with (one's keys), or ending every token one has: a
+ /// user not narrowed by a token's limit. A token handed over to read the member list
+ /// for five minutes adds no key that logs in as its user.
+ Credentials,
/// A right on protocol data.
Protocol(Right, Vec<String>),
}
@@ -271,6 +276,8 @@ fn needed(method: &Method, path: &str, node: &str) -> Need {
let local = |right, name: &str| protocol(right, &[meta::LOCAL, node, name]);
if under("/store") || under("/stream") {
Need::Store
+ } else if (under("/auth/keys") && !looking) || path == "/auth/logout" {
+ Need::Credentials
} else if under("/auth") {
Need::Auth
} else if (path == "/peers" && looking) || path == "/admin/stats" || under("/admin/dashboard") {
@@ -307,6 +314,10 @@ fn needed(method: &Method, path: &str, node: &str) -> Need {
fn allowed(need: Need, access: &Access) -> bool {
match need {
Need::Store | Need::Auth => true,
+ Need::Credentials => match access {
+ Access::Everything => true,
+ Access::Caps(caps) => !caps.limited(),
+ },
Need::Observe => match access {
Access::Everything => true,
Access::Caps(caps) => !caps.is_empty(),
@@ -413,6 +424,17 @@ mod tests {
assert!(allowed(need(&Method::GET, "/admin/stats"), &handed));
assert!(allowed(need(&Method::GET, "/peers"), &handed));
for (method, at) in [
+ (Method::POST, "/auth/refresh"),
+ (Method::DELETE, "/auth/session"),
+ (Method::GET, "/auth/whoami"),
+ (Method::GET, "/auth/keys"),
+ ] {
+ assert!(allowed(need(&method, at), &handed), "{method} {at}");
+ }
+ for (method, at) in [
+ (Method::PUT, "/auth/keys/laptop"),
+ (Method::DELETE, "/auth/keys/laptop"),
+ (Method::POST, "/auth/logout"),
(Method::DELETE, "/peers/continuum:00"),
(Method::POST, "/admin/remakes"),
(Method::GET, "/admin/document"),