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:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -408,6 +408,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -1374,6 +1380,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"data-encoding",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
|||||||
@@ -66,11 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
# Auth (admin users + sessions)
|
# Auth (admin users + sessions + API keys)
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = { version = "0.8", features = ["getrandom"] }
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
data-encoding = "2.6"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ argon2.workspace = true
|
|||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use picloud_shared::InstanceRole;
|
|||||||
|
|
||||||
use crate::admin_session_repo::AdminSessionRepository;
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::api_key_repo::ApiKeyRepository;
|
||||||
use crate::auth::hash_password;
|
use crate::auth::hash_password;
|
||||||
|
|
||||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
/// 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 struct AdminsState {
|
||||||
pub users: Arc<dyn AdminUserRepository>,
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
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 {
|
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?);
|
latest = Some(state.users.set_active(id, new_active).await?);
|
||||||
// Deactivation invalidates all of the user's sessions. Cheap
|
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||||
// and safer than waiting for sliding-window expiry. API key
|
// (cookie / session bearer) and API keys. Both writes are
|
||||||
// expiry on deactivation is wired in the api_keys cascade
|
// logged on failure but do not undo the deactivation; the
|
||||||
// step (see blueprint §11.6 "Deactivation Symmetry").
|
// alternative (leaving the user active when one cascade fails)
|
||||||
|
// is worse than slightly stale credential rows on a DB blip.
|
||||||
if !new_active {
|
if !new_active {
|
||||||
if let Err(err) = state.sessions.delete_for_user(id).await {
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
251
crates/manager-core/src/api_keys_api.rs
Normal file
251
crates/manager-core/src/api_keys_api.rs
Normal file
@@ -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<dyn ApiKeyRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Scope>,
|
||||||
|
/// When set, the key is bound to this app — every `App*(other)`
|
||||||
|
/// capability is denied regardless of role.
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
/// When set, lookup rejects the key after this instant. Absent =
|
||||||
|
/// never expires (until explicit DELETE).
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_<base32>`). 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<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiKeyRow> 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<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<MintApiKeyRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<MintApiKeyResponse>), 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<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<ApiKeyDto>>, 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<ApiKeysState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id): Path<ApiKeyId>,
|
||||||
|
) -> Result<StatusCode, ApiKeysError> {
|
||||||
|
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<AppId>) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt
|
|||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
use data_encoding::BASE32_NOPAD;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
|
|||||||
out
|
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_<base32>`) 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<GeneratedApiKey, argon2::password_hash::Error> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -129,4 +190,39 @@ mod tests {
|
|||||||
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||||
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod admin_user_repo;
|
|||||||
pub mod admin_users_api;
|
pub mod admin_users_api;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod api_key_repo;
|
pub mod api_key_repo;
|
||||||
|
pub mod api_keys_api;
|
||||||
pub mod app_bootstrap;
|
pub mod app_bootstrap;
|
||||||
pub mod app_domain_repo;
|
pub mod app_domain_repo;
|
||||||
pub mod app_members_repo;
|
pub mod app_members_repo;
|
||||||
@@ -41,6 +42,7 @@ pub use api_key_repo::{
|
|||||||
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
||||||
PostgresApiKeyRepository,
|
PostgresApiKeyRepository,
|
||||||
};
|
};
|
||||||
|
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
pub use app_members_repo::{
|
pub use app_members_repo::{
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ use axum::middleware::from_fn_with_state;
|
|||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
|
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
||||||
require_authenticated, route_admin_router, AdminSessionRepository, AdminState,
|
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
||||||
AdminUserRepository, AdminsState, ApiKeyRepository, AppDomainRepository, AppRepository,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||||
AppsState, AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
AppDomainRepository, AppRepository, AppsState, AuthState, PostgresAdminSessionRepository,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppRepository,
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
||||||
|
RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
@@ -148,12 +149,16 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let auth_state = AuthState {
|
let auth_state = AuthState {
|
||||||
users: auth.users.clone(),
|
users: auth.users.clone(),
|
||||||
sessions: auth.sessions.clone(),
|
sessions: auth.sessions.clone(),
|
||||||
keys: auth.keys,
|
keys: auth.keys.clone(),
|
||||||
ttl: auth.ttl,
|
ttl: auth.ttl,
|
||||||
};
|
};
|
||||||
let admins_state = AdminsState {
|
let admins_state = AdminsState {
|
||||||
users: auth.users,
|
users: auth.users,
|
||||||
sessions: auth.sessions,
|
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
|
// /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<Router> {
|
|||||||
.merge(route_admin_router(route_admin))
|
.merge(route_admin_router(route_admin))
|
||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
|
.merge(api_keys_router(api_keys_state))
|
||||||
.layer(from_fn_with_state(
|
.layer(from_fn_with_state(
|
||||||
auth_state.clone(),
|
auth_state.clone(),
|
||||||
require_authenticated,
|
require_authenticated,
|
||||||
|
|||||||
Reference in New Issue
Block a user