mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(admin): allowlist key CRUD + kill-switches [operator §1.5]
Admin-plane key management, all admin_guard-protected and fail-closed audited:
- POST /admin/keys: add a pending key (validated 64-char lowercase x-only hex;
created_by_kind=admin; activation_deadline = now + ADMIN_PENDING_KEY_TTL_DAYS).
Capped at ADMIN_MAX_KEYS live keys (anti allowlist-stuffing).
- GET /admin/keys: list active/pending/revoked (sanitized view + status).
- DELETE /admin/keys/🆔 soft-revoke + cascade-revoke its sessions + audit in ONE
tx. Refuses to revoke the LAST live key over the network (CLI-only), so an
operator can't lock everyone out.
- POST /admin/keys/:id/rotate: atomic revoke-old(+sessions)+add-new(pending)+audit
in one tx — permitted on the last key (add+revoke, not bare revoke), the in-band
recovery for a compromised solo key.
db: create_admin_key refactored onto a shared insert_admin_key(&mut PgConnection)
helper; create_admin_key_audited / revoke_admin_key_full / rotate_admin_key run
their state change + session cascade + hash-chained audit in a single transaction.
1 L1 test extended to cover add/list/rotate/revoke + malformed-pubkey rejection.
NOTE: the live-key cap and last-key revoke guard are count-based on a table the
shared test DB pollutes across suites, so their boundaries aren't integration-
asserted (covered by review); the happy path raises the cap.
This commit is contained in:
+210
-4
@@ -16,12 +16,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use chrono::{Duration, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -35,7 +35,7 @@ use crate::core::admin_session::AdminSessionManager;
|
||||
use crate::core::crypto::nip98::{descriptor_sha256, request_descriptor, verify_signed_action};
|
||||
use crate::core::session::hash_refresh_token;
|
||||
use crate::error::AppError;
|
||||
use crate::models::db::{NewAdminAudit, NewAdminSession};
|
||||
use crate::models::db::{AdminKey, NewAdminAudit, NewAdminKey, NewAdminSession};
|
||||
use crate::AppState;
|
||||
|
||||
/// Admin-login nonce lifetime.
|
||||
@@ -77,6 +77,32 @@ fn pubkey_prefix(pubkey: &str) -> String {
|
||||
pubkey.chars().take(PUBKEY_PREFIX_LEN).collect()
|
||||
}
|
||||
|
||||
/// Validate an x-only secp256k1 pubkey: exactly 64 lowercase hex chars.
|
||||
fn validate_admin_pubkey(pubkey: &str) -> Result<(), AppError> {
|
||||
let ok = pubkey.len() == 64
|
||||
&& pubkey
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::ValidationError(
|
||||
"pubkey must be 64 lowercase hex chars".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle label for an allowlist row.
|
||||
fn key_status(k: &AdminKey) -> &'static str {
|
||||
if k.revoked_at.is_some() {
|
||||
"revoked"
|
||||
} else if k.activated_at.is_none() {
|
||||
"pending"
|
||||
} else {
|
||||
"active"
|
||||
}
|
||||
}
|
||||
|
||||
// ── guard ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// AUTHN (valid admin access token + live session) + AUTHZ (active, MAC-valid,
|
||||
@@ -382,6 +408,183 @@ pub async fn admin_me(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── keys CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddKeyRequest {
|
||||
pub pubkey: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KeyResponse {
|
||||
pub id: String,
|
||||
pub pubkey: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminKeyView {
|
||||
pub id: String,
|
||||
pub pubkey: String,
|
||||
pub label: Option<String>,
|
||||
pub scope: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub activated_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KeysListResponse {
|
||||
pub keys: Vec<AdminKeyView>,
|
||||
}
|
||||
|
||||
fn key_view(k: &AdminKey) -> AdminKeyView {
|
||||
AdminKeyView {
|
||||
id: k.id.to_string(),
|
||||
pubkey: k.pubkey.clone(),
|
||||
label: k.label.clone(),
|
||||
scope: k.scope.clone(),
|
||||
status: key_status(k).to_string(),
|
||||
created_at: k.created_at.to_rfc3339(),
|
||||
activated_at: k.activated_at.map(|t| t.to_rfc3339()),
|
||||
revoked_at: k.revoked_at.map(|t| t.to_rfc3339()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a pending `NewAdminKey` from a validated request, created over the
|
||||
/// network (so `created_by_kind = admin`), with the configured activation TTL.
|
||||
fn pending_key(state: &AppState, pubkey: String, label: Option<String>) -> NewAdminKey {
|
||||
NewAdminKey {
|
||||
pubkey,
|
||||
label,
|
||||
scope: "admin".into(),
|
||||
created_by_kind: "admin".into(),
|
||||
activation_deadline: Some(
|
||||
Utc::now() + Duration::days(state.config.admin.pending_key_ttl_days as i64),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a pending allowlist entry (it activates on its holder's first login).
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn admin_keys_add(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<AddKeyRequest>,
|
||||
) -> Result<Json<KeyResponse>, AppError> {
|
||||
let ctx = admin_guard(&state, &headers).await?;
|
||||
let pubkey = req.pubkey.to_lowercase();
|
||||
validate_admin_pubkey(&pubkey)?;
|
||||
|
||||
// Cap total live (active + pending) keys to prevent allowlist-stuffing.
|
||||
if state.db.count_live_admin_keys().await? >= state.config.admin.max_keys as i64 {
|
||||
return Err(AppError::Forbidden(
|
||||
"admin key limit reached; revoke an existing key first".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let secret = &state.config.admin.key_integrity_secret;
|
||||
let audit = NewAdminAudit {
|
||||
action: "admin_key_added".into(),
|
||||
actor_kind: "admin".into(),
|
||||
actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)),
|
||||
target: Some(pubkey_prefix(&pubkey)),
|
||||
details: None,
|
||||
ip_address: None,
|
||||
};
|
||||
let key = state
|
||||
.db
|
||||
.create_admin_key_audited(pending_key(&state, pubkey, req.label), &audit, secret)
|
||||
.await?;
|
||||
let status = key_status(&key).to_string();
|
||||
Ok(Json(KeyResponse {
|
||||
id: key.id.to_string(),
|
||||
pubkey: key.pubkey,
|
||||
status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all allowlist entries (active, pending, revoked).
|
||||
#[instrument(skip(state, headers))]
|
||||
pub async fn admin_keys_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<KeysListResponse>, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let keys = state.db.list_admin_keys().await?;
|
||||
Ok(Json(KeysListResponse {
|
||||
keys: keys.iter().map(key_view).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Soft-revoke a key (+ its sessions). Refuses to revoke the LAST live key over
|
||||
/// the network — that is CLI-only, so an operator cannot lock everyone out.
|
||||
#[instrument(skip(state, headers))]
|
||||
pub async fn admin_keys_revoke(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<OkResponse>, AppError> {
|
||||
let ctx = admin_guard(&state, &headers).await?;
|
||||
if state.db.count_live_admin_keys().await? <= 1 {
|
||||
return Err(AppError::Forbidden(
|
||||
"refusing to revoke the last admin key over the network; use the CLI".into(),
|
||||
));
|
||||
}
|
||||
let secret = &state.config.admin.key_integrity_secret;
|
||||
let audit = NewAdminAudit {
|
||||
action: "admin_key_revoked".into(),
|
||||
actor_kind: "admin".into(),
|
||||
actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)),
|
||||
target: Some(id.to_string()),
|
||||
details: None,
|
||||
ip_address: None,
|
||||
};
|
||||
state
|
||||
.db
|
||||
.revoke_admin_key_full(id, &audit, secret)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("admin key not found or already revoked".into()))?;
|
||||
Ok(Json(OkResponse { ok: true }))
|
||||
}
|
||||
|
||||
/// Atomically rotate a key: revoke the old one (+ its sessions) and add a fresh
|
||||
/// pending one. Permitted on the last live key (it is add+revoke, not a bare
|
||||
/// revoke), so it is the in-band recovery for a compromised solo key.
|
||||
#[instrument(skip(state, headers, req))]
|
||||
pub async fn admin_keys_rotate(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<AddKeyRequest>,
|
||||
) -> Result<Json<KeyResponse>, AppError> {
|
||||
let ctx = admin_guard(&state, &headers).await?;
|
||||
let pubkey = req.pubkey.to_lowercase();
|
||||
validate_admin_pubkey(&pubkey)?;
|
||||
let secret = &state.config.admin.key_integrity_secret;
|
||||
let audit = NewAdminAudit {
|
||||
action: "admin_key_rotated".into(),
|
||||
actor_kind: "admin".into(),
|
||||
actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)),
|
||||
target: Some(id.to_string()),
|
||||
details: None,
|
||||
ip_address: None,
|
||||
};
|
||||
let key = state
|
||||
.db
|
||||
.rotate_admin_key(id, pending_key(&state, pubkey, req.label), &audit, secret)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("admin key not found or already revoked".into()))?;
|
||||
let status = key_status(&key).to_string();
|
||||
Ok(Json(KeyResponse {
|
||||
id: key.id.to_string(),
|
||||
pubkey: key.pubkey,
|
||||
status,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket.
|
||||
@@ -392,4 +595,7 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/admin/auth/refresh", post(admin_refresh))
|
||||
.route("/admin/auth/logout", post(admin_logout))
|
||||
.route("/admin/me", get(admin_me))
|
||||
.route("/admin/keys", post(admin_keys_add).get(admin_keys_list))
|
||||
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
||||
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
||||
}
|
||||
|
||||
+165
-33
@@ -8,6 +8,7 @@
|
||||
//! signed at write equals the value read back.
|
||||
|
||||
use chrono::{SubsecRound, Utc};
|
||||
use sqlx::PgConnection;
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,7 +16,7 @@ use crate::core::crypto::admin_mac::{
|
||||
compute_admin_key_mac, verify_admin_key_mac, AdminKeyMacInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::models::db::{AdminKey, NewAdminKey};
|
||||
use crate::models::db::{AdminKey, NewAdminAudit, NewAdminKey};
|
||||
|
||||
use super::{unique_violation_as, Database};
|
||||
|
||||
@@ -43,38 +44,23 @@ impl Database {
|
||||
input: NewAdminKey,
|
||||
secret: &str,
|
||||
) -> Result<AdminKey, AppError> {
|
||||
let id = Uuid::new_v4();
|
||||
let created_at = Utc::now().trunc_subsecs(6);
|
||||
let mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
id,
|
||||
pubkey: &input.pubkey,
|
||||
scope: &input.scope,
|
||||
created_at,
|
||||
activated_at: None,
|
||||
revoked_at: None,
|
||||
},
|
||||
);
|
||||
let sql = format!(
|
||||
"INSERT INTO admin_keys \
|
||||
(id, pubkey, label, scope, created_at, created_by_kind, activation_deadline, integrity_mac) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}"
|
||||
);
|
||||
let key = sqlx::query_as::<_, AdminKey>(&sql)
|
||||
.bind(id)
|
||||
.bind(&input.pubkey)
|
||||
.bind(&input.label)
|
||||
.bind(&input.scope)
|
||||
.bind(created_at)
|
||||
.bind(&input.created_by_kind)
|
||||
.bind(input.activation_deadline)
|
||||
.bind(&mac)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(unique_violation_as(
|
||||
"an active admin key with this pubkey already exists",
|
||||
))?;
|
||||
let mut conn = self.pool().acquire().await?;
|
||||
insert_admin_key(&mut conn, &input, secret).await
|
||||
}
|
||||
|
||||
/// Add an allowlist entry AND append its audit row in ONE transaction, so the
|
||||
/// audit write is fail-closed (a failed audit rolls back the key creation).
|
||||
#[instrument(skip(self, audit, secret))]
|
||||
pub async fn create_admin_key_audited(
|
||||
&self,
|
||||
input: NewAdminKey,
|
||||
audit: &NewAdminAudit,
|
||||
secret: &str,
|
||||
) -> Result<AdminKey, AppError> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let key = insert_admin_key(&mut tx, &input, secret).await?;
|
||||
self.append_admin_audit(&mut tx, audit, secret).await?;
|
||||
tx.commit().await?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -219,4 +205,150 @@ impl Database {
|
||||
tx.commit().await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
/// Soft-revoke a key, revoke ALL its sessions, and append the audit row — all
|
||||
/// in ONE transaction (so a revoked admin's still-valid access token is
|
||||
/// rejected on its next call, and the audit is fail-closed). Returns the
|
||||
/// updated key, or `None` if it was already revoked / does not exist.
|
||||
#[instrument(skip(self, audit, secret))]
|
||||
pub async fn revoke_admin_key_full(
|
||||
&self,
|
||||
id: Uuid,
|
||||
audit: &NewAdminAudit,
|
||||
secret: &str,
|
||||
) -> Result<Option<AdminKey>, AppError> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let sel = format!(
|
||||
"SELECT {COLS} FROM admin_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE"
|
||||
);
|
||||
let Some(key) = sqlx::query_as::<_, AdminKey>(&sel)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let revoked_at = Utc::now().trunc_subsecs(6);
|
||||
let mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
revoked_at: Some(revoked_at),
|
||||
..mac_input(&key)
|
||||
},
|
||||
);
|
||||
let upd = format!(
|
||||
"UPDATE admin_keys SET revoked_at = $2, integrity_mac = $3 WHERE id = $1 RETURNING {COLS}"
|
||||
);
|
||||
let updated = sqlx::query_as::<_, AdminKey>(&upd)
|
||||
.bind(id)
|
||||
.bind(revoked_at)
|
||||
.bind(&mac)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE admin_sessions SET revoked_at = NOW() \
|
||||
WHERE admin_key_id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
self.append_admin_audit(&mut tx, audit, secret).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
/// Atomically rotate a key: revoke `old_id` (+ its sessions), create a fresh
|
||||
/// pending key from `new`, and audit — all in one transaction. This is the
|
||||
/// in-band recovery for a compromised solo key (add+revoke, so it never leaves
|
||||
/// the allowlist empty). Returns the new key, or `None` if `old_id` was
|
||||
/// already revoked / does not exist; 409 if the new pubkey is already active.
|
||||
#[instrument(skip(self, new, audit, secret))]
|
||||
pub async fn rotate_admin_key(
|
||||
&self,
|
||||
old_id: Uuid,
|
||||
new: NewAdminKey,
|
||||
audit: &NewAdminAudit,
|
||||
secret: &str,
|
||||
) -> Result<Option<AdminKey>, AppError> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let sel = format!(
|
||||
"SELECT {COLS} FROM admin_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE"
|
||||
);
|
||||
let Some(old) = sqlx::query_as::<_, AdminKey>(&sel)
|
||||
.bind(old_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let revoked_at = Utc::now().trunc_subsecs(6);
|
||||
let old_mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
revoked_at: Some(revoked_at),
|
||||
..mac_input(&old)
|
||||
},
|
||||
);
|
||||
sqlx::query("UPDATE admin_keys SET revoked_at = $2, integrity_mac = $3 WHERE id = $1")
|
||||
.bind(old_id)
|
||||
.bind(revoked_at)
|
||||
.bind(&old_mac)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE admin_sessions SET revoked_at = NOW() \
|
||||
WHERE admin_key_id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(old_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let created = insert_admin_key(&mut tx, &new, secret).await?;
|
||||
self.append_admin_audit(&mut tx, audit, secret).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(created))
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a pending allowlist entry on `conn` (so it can share a caller's
|
||||
/// transaction). Computes the integrity MAC over the exact stored values. 409 if
|
||||
/// an active key with this pubkey already exists.
|
||||
async fn insert_admin_key(
|
||||
conn: &mut PgConnection,
|
||||
input: &NewAdminKey,
|
||||
secret: &str,
|
||||
) -> Result<AdminKey, AppError> {
|
||||
let id = Uuid::new_v4();
|
||||
let created_at = Utc::now().trunc_subsecs(6);
|
||||
let mac = compute_admin_key_mac(
|
||||
secret,
|
||||
&AdminKeyMacInput {
|
||||
id,
|
||||
pubkey: &input.pubkey,
|
||||
scope: &input.scope,
|
||||
created_at,
|
||||
activated_at: None,
|
||||
revoked_at: None,
|
||||
},
|
||||
);
|
||||
let sql = format!(
|
||||
"INSERT INTO admin_keys \
|
||||
(id, pubkey, label, scope, created_at, created_by_kind, activation_deadline, integrity_mac) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}"
|
||||
);
|
||||
sqlx::query_as::<_, AdminKey>(&sql)
|
||||
.bind(id)
|
||||
.bind(&input.pubkey)
|
||||
.bind(&input.label)
|
||||
.bind(&input.scope)
|
||||
.bind(created_at)
|
||||
.bind(&input.created_by_kind)
|
||||
.bind(input.activation_deadline)
|
||||
.bind(&mac)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.map_err(unique_violation_as(
|
||||
"an active admin key with this pubkey already exists",
|
||||
))
|
||||
}
|
||||
|
||||
+90
-1
@@ -9,12 +9,34 @@ mod common;
|
||||
use axum::http::StatusCode;
|
||||
use base64::Engine;
|
||||
use k256::schnorr::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descriptor};
|
||||
use smirk_backend_core::models::db::NewAdminKey;
|
||||
|
||||
const NIP98_KIND: u32 = 27235;
|
||||
|
||||
/// A fresh, valid schnorr signer (random per run, so the shared test DB's unique
|
||||
/// active-pubkey constraint never collides across runs).
|
||||
fn random_signer() -> SigningKey {
|
||||
loop {
|
||||
let mut b = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut b);
|
||||
if let Ok(sk) = SigningKey::from_bytes(&b) {
|
||||
return sk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A random 64-char lowercase hex pubkey (format-valid; no curve point needed for
|
||||
/// allowlist entries that are never signed with in this test).
|
||||
fn random_pubkey_hex() -> String {
|
||||
let mut b = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut b);
|
||||
hex::encode(b)
|
||||
}
|
||||
|
||||
/// Build a `Nostr <base64(event)>` admin_login signed-action token.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sign_admin_login(
|
||||
@@ -63,13 +85,17 @@ async fn admin_login_guard_refresh_logout_flow() {
|
||||
"admin-integrity-secret-at-least-32-bytes!",
|
||||
);
|
||||
std::env::set_var("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081");
|
||||
// The live-key cap + last-key revoke guard are count-based on a table the
|
||||
// shared test DB pollutes across runs/sibling suites, so they can't be
|
||||
// boundary-tested here (covered by review); raise the cap for the happy path.
|
||||
std::env::set_var("ADMIN_MAX_KEYS", "1000000");
|
||||
|
||||
let app = require_app!();
|
||||
let admin = app.admin_router();
|
||||
let secret = app.state.config.admin.key_integrity_secret.clone();
|
||||
|
||||
// Seed a PENDING admin key for our signer.
|
||||
let sk = SigningKey::from_bytes(&[9u8; 32]).unwrap();
|
||||
let sk = random_signer();
|
||||
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
|
||||
app.state
|
||||
.db
|
||||
@@ -142,6 +168,68 @@ async fn admin_login_guard_refresh_logout_flow() {
|
||||
"user token rejected on admin plane"
|
||||
);
|
||||
|
||||
// 4b. Keys CRUD as the activated admin. (The last-key revoke guard is not
|
||||
// asserted here: live-key count is global to the shared test DB and polluted
|
||||
// by sibling test files, so it cannot be forced to 1 deterministically.)
|
||||
let new_pk = random_pubkey_hex();
|
||||
let (st, body) = app
|
||||
.request_on(
|
||||
admin.clone(),
|
||||
"POST",
|
||||
"/admin/keys",
|
||||
Some(&access),
|
||||
Some(serde_json::json!({ "pubkey": new_pk, "label": "second" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "add key: {body:?}");
|
||||
assert_eq!(body["status"].as_str().unwrap(), "pending");
|
||||
let added_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
// A malformed pubkey is rejected.
|
||||
let (st, _) = app
|
||||
.request_on(
|
||||
admin.clone(),
|
||||
"POST",
|
||||
"/admin/keys",
|
||||
Some(&access),
|
||||
Some(serde_json::json!({ "pubkey": "nothex" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "bad pubkey rejected");
|
||||
|
||||
// List shows at least our two keys.
|
||||
let (st, body) = app
|
||||
.request_on(admin.clone(), "GET", "/admin/keys", Some(&access), None)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert!(body["keys"].as_array().unwrap().len() >= 2);
|
||||
|
||||
// Rotate the added key -> a fresh pending key.
|
||||
let rot_pk = random_pubkey_hex();
|
||||
let (st, body) = app
|
||||
.request_on(
|
||||
admin.clone(),
|
||||
"POST",
|
||||
&format!("/admin/keys/{added_id}/rotate"),
|
||||
Some(&access),
|
||||
Some(serde_json::json!({ "pubkey": rot_pk })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "rotate: {body:?}");
|
||||
let rotated_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Revoke the rotated key (more than one live key exists, so it is permitted).
|
||||
let (st, _) = app
|
||||
.request_on(
|
||||
admin.clone(),
|
||||
"DELETE",
|
||||
&format!("/admin/keys/{rotated_id}"),
|
||||
Some(&access),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "revoke");
|
||||
|
||||
// 5. Replay: the consumed challenge cannot be reused.
|
||||
let (st, _) = app
|
||||
.request_on(
|
||||
@@ -201,6 +289,7 @@ async fn admin_login_guard_refresh_logout_flow() {
|
||||
"ADMIN_JWT_SECRET",
|
||||
"ADMIN_KEY_INTEGRITY_SECRET",
|
||||
"ADMIN_PUBLIC_URL",
|
||||
"ADMIN_MAX_KEYS",
|
||||
] {
|
||||
std::env::remove_var(k);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user