diff --git a/Cargo.lock b/Cargo.lock index 8bb8803..1375ff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,6 +408,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -1374,6 +1380,7 @@ dependencies = [ "axum", "base64", "chrono", + "data-encoding", "picloud-orchestrator-core", "picloud-shared", "rand 0.8.6", diff --git a/Cargo.toml b/Cargo.toml index 13fe214..7952bb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,11 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus url = "2" urlencoding = "2" -# Auth (admin users + sessions) +# Auth (admin users + sessions + API keys) argon2 = "0.5" rand = { version = "0.8", features = ["getrandom"] } sha2 = "0.10" base64 = "0.22" +data-encoding = "2.6" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml index 4b6f683..a1feeb1 100644 --- a/crates/manager-core/Cargo.toml +++ b/crates/manager-core/Cargo.toml @@ -27,6 +27,7 @@ argon2.workspace = true rand.workspace = true sha2.workspace = true base64.workspace = true +data-encoding.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/manager-core/src/admin_users_api.rs b/crates/manager-core/src/admin_users_api.rs index e44da5c..5bd5466 100644 --- a/crates/manager-core/src/admin_users_api.rs +++ b/crates/manager-core/src/admin_users_api.rs @@ -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, pub sessions: Arc, + /// 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, } 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"); + } + } } } diff --git a/crates/manager-core/src/api_keys_api.rs b/crates/manager-core/src/api_keys_api.rs new file mode 100644 index 0000000..e86b40d --- /dev/null +++ b/crates/manager-core/src/api_keys_api.rs @@ -0,0 +1,251 @@ +//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6). +//! +//! All endpoints are guarded by `require_authenticated`. Capability +//! checks: none — every authenticated user manages **their own** keys. +//! The repo enforces caller ownership on `delete`, and `list` is +//! scoped to the caller's user_id. No instance-level authority is +//! exposed (no listing other users' keys, no admin-issued keys for +//! another user — those flows belong with the invite system). +//! +//! Mint semantics: +//! * raw token is returned **exactly once** in the POST response and +//! never logged. Lose it = mint a new key. +//! * `app_id` (optional) binds the key to one app; capability checks +//! deny every `App*(other_app)`. +//! * scopes containing `instance:*` are rejected when `app_id` is +//! set — the combination is irreconcilable. + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::{delete, get}; +use axum::{Extension, Router}; +use chrono::{DateTime, Utc}; +use picloud_shared::{ApiKeyId, AppId, Principal, Scope}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey}; +use crate::auth::generate_api_key; + +/// Validation bounds for the user-supplied `name` field — keeps the +/// dashboard's list view tidy and rejects accidental whole-token +/// pastes. +const NAME_MIN: usize = 1; +const NAME_MAX: usize = 64; + +#[derive(Clone)] +pub struct ApiKeysState { + pub keys: Arc, +} + +pub fn api_keys_router(state: ApiKeysState) -> Router { + Router::new() + .route("/api-keys", get(list_keys).post(mint_key)) + .route("/api-keys/{id}", delete(delete_key)) + .with_state(state) +} + +// ---------------------------------------------------------------------------- +// DTOs +// ---------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct MintApiKeyRequest { + pub name: String, + pub scopes: Vec, + /// When set, the key is bound to this app — every `App*(other)` + /// capability is denied regardless of role. + #[serde(default)] + pub app_id: Option, + /// When set, lookup rejects the key after this instant. Absent = + /// never expires (until explicit DELETE). + #[serde(default)] + pub expires_at: Option>, +} + +/// Response body for a freshly-minted key. `raw_token` only appears +/// here — `GET /api-keys` returns `ApiKeyDto` without it. +#[derive(Debug, Serialize)] +pub struct MintApiKeyResponse { + #[serde(flatten)] + pub key: ApiKeyDto, + /// The full wire-format token (`pic_`). Shown exactly once; + /// store it client-side immediately. + pub raw_token: String, +} + +#[derive(Debug, Serialize)] +pub struct ApiKeyDto { + pub id: ApiKeyId, + pub prefix: String, + pub name: String, + pub scopes: Vec, + pub app_id: Option, + pub expires_at: Option>, + pub last_used_at: Option>, + pub created_at: DateTime, +} + +impl From for ApiKeyDto { + fn from(r: ApiKeyRow) -> Self { + Self { + id: r.id, + prefix: r.prefix, + name: r.name, + scopes: r.scopes, + app_id: r.app_id, + expires_at: r.expires_at, + last_used_at: r.last_used_at, + created_at: r.created_at, + } + } +} + +// ---------------------------------------------------------------------------- +// Handlers +// ---------------------------------------------------------------------------- + +async fn mint_key( + State(state): State, + Extension(principal): Extension, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiKeysError> { + validate_name(&input.name)?; + validate_scopes(&input.scopes, input.app_id)?; + + let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?; + let row = state + .keys + .create(NewApiKey { + user_id: principal.user_id, + hash: minted.hash, + prefix: minted.prefix, + name: input.name, + scopes: input.scopes, + app_id: input.app_id, + expires_at: input.expires_at, + }) + .await?; + Ok(( + StatusCode::CREATED, + Json(MintApiKeyResponse { + key: row.into(), + raw_token: minted.raw, + }), + )) +} + +async fn list_keys( + State(state): State, + Extension(principal): Extension, +) -> Result>, ApiKeysError> { + let rows = state.keys.list_for_user(principal.user_id).await?; + Ok(Json(rows.into_iter().map(Into::into).collect())) +} + +async fn delete_key( + State(state): State, + Extension(principal): Extension, + Path(id): Path, +) -> Result { + let deleted = state + .keys + .delete_by_id_and_user(id, principal.user_id) + .await?; + if !deleted { + // 404 covers both "doesn't exist" and "exists but not yours" — + // we deliberately don't leak the distinction. + return Err(ApiKeysError::NotFound(id)); + } + Ok(StatusCode::NO_CONTENT) +} + +// ---------------------------------------------------------------------------- +// Validation +// ---------------------------------------------------------------------------- + +fn validate_name(s: &str) -> Result<(), ApiKeysError> { + let trimmed = s.trim(); + if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX { + return Err(ApiKeysError::InvalidName(format!( + "name must be {NAME_MIN}-{NAME_MAX} characters after trimming" + ))); + } + Ok(()) +} + +fn validate_scopes(scopes: &[Scope], app_id: Option) -> Result<(), ApiKeysError> { + if scopes.is_empty() { + return Err(ApiKeysError::InvalidScopes( + "scopes must be non-empty".into(), + )); + } + // Bound key + any instance:* scope → irreconcilable. + if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) { + return Err(ApiKeysError::InvalidScopes( + "bound keys (app_id set) cannot carry instance:* scopes".into(), + )); + } + Ok(()) +} + +// ---------------------------------------------------------------------------- +// Errors +// ---------------------------------------------------------------------------- + +#[derive(Debug, thiserror::Error)] +pub enum ApiKeysError { + #[error("api key not found: {0}")] + NotFound(ApiKeyId), + + #[error("{0}")] + InvalidName(String), + + #[error("{0}")] + InvalidScopes(String), + + #[error("failed to hash key: {0}")] + Hash(String), + + #[error("repository error: {0}")] + Repo(#[from] ApiKeyRepositoryError), +} + +impl IntoResponse for ApiKeysError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + Self::InvalidName(_) | Self::InvalidScopes(_) => { + (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) + } + Self::Hash(_) => { + tracing::error!(error = %self, "api key hash failure"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + Self::Repo(ApiKeyRepositoryError::NotFound(_)) => { + (StatusCode::NOT_FOUND, self.to_string()) + } + Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => { + tracing::error!(error = %self, "api key row carries an unknown scope"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + Self::Repo(ApiKeyRepositoryError::Db(e)) => { + tracing::error!(error = %e, "api_keys db error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} diff --git a/crates/manager-core/src/auth.rs b/crates/manager-core/src/auth.rs index 7181b38..ac838bb 100644 --- a/crates/manager-core/src/auth.rs +++ b/crates/manager-core/src/auth.rs @@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt use argon2::Argon2; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; +use data_encoding::BASE32_NOPAD; use rand::rngs::OsRng; use rand::RngCore; use sha2::{Digest, Sha256}; @@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String { out } +// ---------------------------------------------------------------------------- +// API key generation (Phase 3.5) +// ---------------------------------------------------------------------------- + +/// Wire-format prefix that marks a Bearer value as an API key (vs. a +/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the +/// generator and the verifier agree. +pub const API_KEY_WIRE_PREFIX: &str = "pic_"; + +/// Length of the indexed prefix portion (the first 8 chars of the +/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`. +pub const API_KEY_INDEX_PREFIX_LEN: usize = 8; + +/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`. +/// +/// * `raw` is the full wire-format token (`pic_`) shown to the +/// caller in the response body and never persisted. +/// * `prefix` is the indexed 8-char slice persisted to +/// `api_keys.prefix` for lookup. +/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`; +/// covers the body after `pic_` (i.e., `raw[4..]`). +pub struct GeneratedApiKey { + pub raw: String, + pub prefix: String, + pub hash: String, +} + +/// Generate a fresh API key. 32 random bytes → unpadded base32, then +/// `pic_` prefix on the wire. The first 8 base32 chars are the index +/// key; everything after `pic_` is what the verifier hashes. +/// +/// # Errors +/// +/// Returns `argon2::password_hash::Error` if the Argon2 hash step +/// fails (which it shouldn't under normal conditions). +pub fn generate_api_key() -> Result { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let body = BASE32_NOPAD.encode(&bytes); + debug_assert!( + body.len() >= API_KEY_INDEX_PREFIX_LEN, + "32 bytes base32 must exceed the 8-char prefix length" + ); + let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string(); + let salt = SaltString::generate(&mut ArgonRng); + let hash = Argon2::default() + .hash_password(body.as_bytes(), &salt)? + .to_string(); + let raw = format!("{API_KEY_WIRE_PREFIX}{body}"); + Ok(GeneratedApiKey { raw, prefix, hash }) +} + +/// Verify a wire-format token body (the portion *after* `pic_`) +/// against a stored Argon2id hash. Convenience wrapper around +/// `verify_password` named to reflect its caller. +#[must_use] +pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool { + verify_password(stored_hash, presented_body) +} + #[cfg(test)] mod tests { use super::*; @@ -129,4 +190,39 @@ mod tests { assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible"); assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars"); } + + #[test] + fn generate_api_key_round_trip() { + let key = generate_api_key().expect("mint"); + assert!( + key.raw.starts_with(API_KEY_WIRE_PREFIX), + "raw must carry the pic_ prefix" + ); + let body = key + .raw + .strip_prefix(API_KEY_WIRE_PREFIX) + .expect("starts with prefix"); + assert_eq!( + &body[..API_KEY_INDEX_PREFIX_LEN], + key.prefix, + "stored prefix matches the first 8 chars of the body" + ); + assert!( + verify_api_key(&key.hash, body), + "Argon2 verify must accept the original body" + ); + assert!( + !verify_api_key(&key.hash, "wrong-body-entirely"), + "Argon2 verify must reject anything else" + ); + } + + #[test] + fn generate_api_key_unique() { + let a = generate_api_key().expect("mint a"); + let b = generate_api_key().expect("mint b"); + assert_ne!(a.raw, b.raw); + assert_ne!(a.hash, b.hash); + assert_ne!(a.prefix, b.prefix, "32 random bytes → prefix collision is negligible"); + } } diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index d8ed761..d24fd0b 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod admin_user_repo; pub mod admin_users_api; pub mod api; pub mod api_key_repo; +pub mod api_keys_api; pub mod app_bootstrap; pub mod app_domain_repo; pub mod app_members_repo; @@ -41,6 +42,7 @@ pub use api_key_repo::{ ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey, PostgresApiKeyRepository, }; +pub use api_keys_api::{api_keys_router, ApiKeysState}; pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome}; pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository}; pub use app_members_repo::{ diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 5b0e025..b0f551b 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -10,13 +10,14 @@ use axum::middleware::from_fn_with_state; use axum::{routing::get, Json, Router}; use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ - admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations, - require_authenticated, route_admin_router, AdminSessionRepository, AdminState, - AdminUserRepository, AdminsState, ApiKeyRepository, AppDomainRepository, AppRepository, - AppsState, AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository, - PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppRepository, - PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository, - PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, + admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router, + compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository, + AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, + AppDomainRepository, AppRepository, AppsState, AuthState, PostgresAdminSessionRepository, + PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, + PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, + PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, + RouteRepository, SandboxCeiling, }; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::{ @@ -148,12 +149,16 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { let auth_state = AuthState { users: auth.users.clone(), sessions: auth.sessions.clone(), - keys: auth.keys, + keys: auth.keys.clone(), ttl: auth.ttl, }; let admins_state = AdminsState { users: auth.users, sessions: auth.sessions, + keys: auth.keys.clone(), + }; + let api_keys_state = ApiKeysState { + keys: auth.keys, }; // /admin/auth/login + /logout are unguarded by design (login is how @@ -167,6 +172,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { .merge(route_admin_router(route_admin)) .merge(admins_router(admins_state)) .merge(apps_router(apps_state)) + .merge(api_keys_router(api_keys_state)) .layer(from_fn_with_state( auth_state.clone(), require_authenticated,