diff options
| author | tv <tv@krebsco.de> | 2026-11-08 15:43:45 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 16:49:56 +0100 |
| commit | ed27acb707d940c5b796b1079d3d74698484aeba (patch) | |
| tree | 2761073c72f91ad8f3f0ed4b38844bb8ab8d14a2 /src/api/users.rs | |
| parent | 1cdf5ce6e2a935ec17f800706aa8f289036d90f7 (diff) | |
users: a user manages its own keys
Only a user who may manage users could change a user's keys: a user
couldn't add a new laptop's key, or remove a lost one.
A user now manages its own keys, whatever else it may do:
* GET /auth/keys: its keys, its entry, and the envelope holding it
under a strict policy (`signed`), to sign against.
* PUT /auth/keys/{id} with an authorized_keys line, DELETE
/auth/keys/{id}: one change, as the user. The rules take a write of
_continuum/users/<name>/keys/… from the user it names.
* Under a strict policy, the user signs its entry with only its keys
changed, with a key it holds already (envelope::check takes that from
the user itself), PUT to its own entry even without write on the
registry; continuum-signed key-add and key-remove do so.
Diffstat (limited to 'src/api/users.rs')
| -rw-r--r-- | src/api/users.rs | 103 |
1 files changed, 103 insertions, 0 deletions
diff --git a/src/api/users.rs b/src/api/users.rs index 8972566..122fc4b 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -18,6 +18,15 @@ //! one exists. How a swarm without a registry gets one, the caller among those who //! may manage users. //! +//! A user manages its own keys (`keys`, `authorized_keys` lines), whatever it may do +//! otherwise: +//! +//! * `GET /auth/keys`: the caller's keys, its entry, and the envelope holding it under a +//! strict policy (`signed`), to sign the next one against. +//! * `PUT /auth/keys/{id}`: one added or replaced (the line as the body); +//! `DELETE /auth/keys/{id}` removes one. Under a strict policy `409`: the user signs +//! its entry with the keys changed, nothing else (`continuum-signed key-add`). +//! //! Looking needs `read` on `_continuum/users`, changing `write` on it (in a swarm //! without a registry, being in `--protocol-writers`). Every change is one change to //! the document, logged, named by who made it, and refused (`409`) where it would lock @@ -129,6 +138,100 @@ fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> { Some(entry) } +/// `GET /auth/keys`. +pub(crate) async fn own_keys( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, +) -> Result<Json<JsonValue>, ApiError> { + let replica = state.doc.read().await; + let path = registry::user_path(&user.0); + let entry = store::read_plain(&replica.doc, &path) + .ok() + .flatten() + .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, Some("no entry of yours".to_owned())))?; + let signed = match store::read_path(&replica.doc, &path) { + Ok(Some(JsonValue::String(text))) if text.starts_with("cv1.") => Some(text), + _ => None, + }; + Ok(Json(json!({ + "keys": entry.get("keys").cloned().unwrap_or_else(|| json!({})), + "entry": entry, + "signed": signed, + }))) +} + +/// `PUT /auth/keys/{id}`. +pub(crate) async fn put_own_key( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Path(id): Path<String>, + body: String, +) -> Result<StatusCode, ApiError> { + let key = crate::access::sshsig::PublicKey::parse(body.trim()).map_err(|why| bad(&why))?; + own_key(&state, &user.0, id, Some(key.line())).await +} + +/// `DELETE /auth/keys/{id}`. +pub(crate) async fn delete_own_key( + State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Path(id): Path<String>, +) -> Result<StatusCode, ApiError> { + own_key(&state, &user.0, id, None).await +} + +/// `user`'s key `id` set to `line`, or removed: one change, as `user`. +async fn own_key( + state: &AppState, + user: &str, + id: String, + line: Option<String>, +) -> Result<StatusCode, ApiError> { + if !registry::valid_name(&id) { + return Err(bad("not a key's name: 1 to 64 of A-Z a-z 0-9 . _ @ -")); + } + if !state.is_ready().await { + return Err(StatusCode::SERVICE_UNAVAILABLE.into()); + } + let entry = registry::user_path(user); + let mut path = entry.clone(); + path.extend(["keys".to_owned(), id.clone()]); + let adding = line.is_some(); + state + .mutate_doc_for(Some(user.to_owned()), Sign::Protocol, move |doc| { + if crate::access::policy::of(&*doc).strict() { + return Err(ApiError( + StatusCode::CONFLICT, + Some( + "users sign their entries (strict policy): sign yours with the keys \ + changed (continuum-signed key-add, key-remove)" + .to_owned(), + ), + )); + } + if store::read_path(&*doc, &entry)?.is_none() { + return Err(ApiError( + StatusCode::NOT_FOUND, + Some("no entry of yours".to_owned()), + )); + } + match line { + Some(line) => store::write_path(doc, &path, &json!(line))?, + None => match store::delete_path(doc, &path, false) { + Ok(()) => {} + Err(crate::document::store::StoreError::NotFound) => { + return Err(ApiError(StatusCode::NOT_FOUND, None)); + } + Err(error) => return Err(error.into()), + }, + } + Ok(()) + }) + .await?; + info!(user = %user, key = %id, "{}", if adding { "Set own key" } else { "Removed own key" }); + Ok(StatusCode::NO_CONTENT) +} + /// `GET /admin/users`. pub(crate) async fn list(State(state): State<AppState>) -> Json<JsonValue> { let replica = state.doc.read().await; |
