feat(manager-core,picloud): api_keys_api + deactivation cascade
* auth: generate_api_key() mints pic_<base32(32 bytes)>, splits the
indexed 8-char prefix, and Argon2-hashes the body. Adds the
data-encoding workspace dep for unpadded base32.
* api_keys_api: POST /api/v1/admin/api-keys (mint, returns raw_token
exactly once), GET (caller's own, no raw), DELETE {id} (caller's
own; 404 deliberately covers both 'missing' and 'not yours').
Mint validation rejects bound keys carrying instance:* scopes (422).
* AdminsState gains the api keys repo; PATCH set_active(false) now
expires every active key for that user alongside session wipe —
Phase 3.5 deactivation symmetry.
* picloud lib wires PostgresApiKeyRepository through AuthDeps into
AdminsState + ApiKeysState; api_keys_router merges into the
guarded_admin layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ use picloud_shared::InstanceRole;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||
use crate::api_key_repo::ApiKeyRepository;
|
||||
use crate::auth::hash_password;
|
||||
|
||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||
@@ -38,6 +39,10 @@ const PASSWORD_MIN: usize = 8;
|
||||
pub struct AdminsState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
|
||||
/// also expires every active API key for that user so cookie and
|
||||
/// bearer credentials become inert at the same moment.
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
}
|
||||
|
||||
pub fn admins_router(state: AdminsState) -> Router {
|
||||
@@ -209,14 +214,25 @@ async fn patch_admin(
|
||||
}
|
||||
}
|
||||
latest = Some(state.users.set_active(id, new_active).await?);
|
||||
// Deactivation invalidates all of the user's sessions. Cheap
|
||||
// and safer than waiting for sliding-window expiry. API key
|
||||
// expiry on deactivation is wired in the api_keys cascade
|
||||
// step (see blueprint §11.6 "Deactivation Symmetry").
|
||||
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||
// (cookie / session bearer) and API keys. Both writes are
|
||||
// logged on failure but do not undo the deactivation; the
|
||||
// alternative (leaving the user active when one cascade fails)
|
||||
// is worse than slightly stale credential rows on a DB blip.
|
||||
if !new_active {
|
||||
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||
}
|
||||
match state.keys.expire_all_for_user(id).await {
|
||||
Ok(n) => {
|
||||
if n > 0 {
|
||||
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user