The data-plane (POST /execute/{id} + user-route fallback) is
unauthenticated by default — public scripts get hit by anonymous HTTP
traffic. But some calls are authed (dashboard test-runs, API-key
invocations) and v1.1.x services will want to see the caller via
`cx.principal` for audit / authz once those features land.
- New manager-core::attach_principal_if_present middleware. Always
inserts Extension<Option<Principal>>: Some on resolved bearer/cookie,
None on absent or malformed token. Fail-open on DB blip so a
transient infra failure can't 500 anonymous traffic.
- Wired in picloud build_app, scoped to the data-plane and user-routes
routers only. The admin path keeps using require_authenticated; no
double-resolve on the same token.
- orchestrator-core handlers (execute_by_id, user_route_handler) now
extract Extension<Option<Principal>> and pass it to build_exec_request.
Replaces the temporary `None` placeholders from the previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
13 KiB
Rust
378 lines
13 KiB
Rust
//! Authentication middleware — resolves the caller's `Principal` from
|
|
//! either a session cookie / Bearer session-token OR an API key
|
|
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
|
//! request extension so downstream handlers see one shape.
|
|
//!
|
|
//! Capability checks live in `crate::authz` and are called per-handler
|
|
//! (after the relevant resource is loaded, so the capability binds to
|
|
//! the actual resource's `app_id`). This middleware is gate-only: it
|
|
//! ensures *some* `Principal` is attached, or returns 401.
|
|
//!
|
|
//! Token discriminator: the `pic_` prefix on a Bearer value selects
|
|
//! the API-key path; anything else (raw 32-byte base64-url-encoded
|
|
//! string) takes the session path. The session cookie can only ever
|
|
//! carry a session token (cookies are never API keys).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::{Request, State};
|
|
use axum::http::{header, StatusCode};
|
|
use axum::middleware::Next;
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use chrono::Utc;
|
|
use picloud_shared::{AdminUserId, Principal};
|
|
use serde_json::json;
|
|
|
|
use crate::admin_session_repo::AdminSessionRepository;
|
|
use crate::admin_user_repo::AdminUserRepository;
|
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
|
use crate::auth::{hash_token, verify_password};
|
|
|
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
|
|
|
/// Prefix on the wire that selects the API-key path. The body that
|
|
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
|
|
/// index into `api_keys.prefix` for verification.
|
|
pub const API_KEY_PREFIX: &str = "pic_";
|
|
|
|
/// Length of the indexed prefix portion of an API key (the 8 chars
|
|
/// immediately after `pic_`). Schema-side index is on this slice.
|
|
pub const API_KEY_PREFIX_LEN: usize = 8;
|
|
|
|
/// Shared state for auth: the user / session / API-key repos plus the
|
|
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
|
|
#[derive(Clone)]
|
|
pub struct AuthState {
|
|
pub users: Arc<dyn AdminUserRepository>,
|
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
|
pub keys: Arc<dyn ApiKeyRepository>,
|
|
pub ttl: Duration,
|
|
}
|
|
|
|
/// Legacy request-extension alias retained so the (only remaining)
|
|
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
|
|
/// keeps compiling during the migration. New handlers should pull
|
|
/// `Extension<Principal>` directly.
|
|
#[deprecated(note = "use Extension<Principal> directly")]
|
|
#[derive(Debug, Clone)]
|
|
pub struct AuthedAdmin {
|
|
pub id: AdminUserId,
|
|
pub username: String,
|
|
}
|
|
|
|
/// Middleware entry point. Wire with
|
|
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
|
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
|
|
/// extensions on success; returns 401 on any failure mode.
|
|
pub async fn require_authenticated(
|
|
State(state): State<AuthState>,
|
|
mut req: Request<Body>,
|
|
next: Next,
|
|
) -> Response {
|
|
let Some(token) = extract_token(&req) else {
|
|
return unauthorized();
|
|
};
|
|
let principal = match resolve_principal(&state, &token).await {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => return unauthorized(),
|
|
Err(InternalError) => return internal_error(),
|
|
};
|
|
|
|
let username_for_legacy = username_for(&state, principal.user_id).await;
|
|
req.extensions_mut().insert(principal.clone());
|
|
#[allow(deprecated)]
|
|
if let Some(username) = username_for_legacy {
|
|
req.extensions_mut().insert(AuthedAdmin {
|
|
id: principal.user_id,
|
|
username,
|
|
});
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
/// Backwards-compatible alias — the single callsite that still names
|
|
/// `require_admin` keeps working without an immediate rename. New
|
|
/// wiring should call `require_authenticated`.
|
|
#[deprecated(note = "renamed to require_authenticated")]
|
|
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
|
require_authenticated(state, req, next).await
|
|
}
|
|
|
|
/// Opportunistic data-plane variant: always inserts an
|
|
/// `Extension<Option<Principal>>` and forwards the request. Used on
|
|
/// `/execute/{id}` and the user-route fallback, where most invocations
|
|
/// are anonymous public HTTP and the few authed ones (dashboard
|
|
/// test-runs, API keys) should still let scripts see the caller via
|
|
/// `cx.principal` once services consume it.
|
|
///
|
|
/// Failure modes — all degrade to `None` rather than rejecting:
|
|
/// * No bearer / cookie → `None`.
|
|
/// * Malformed or unknown token → `None`.
|
|
/// * DB blip while resolving → `None` (fail-open; the data plane
|
|
/// should not 500 on transient infra failures for an *optional*
|
|
/// identity check).
|
|
///
|
|
/// Admin-side routes that REQUIRE an identity keep using
|
|
/// `require_authenticated`.
|
|
pub async fn attach_principal_if_present(
|
|
State(state): State<AuthState>,
|
|
mut req: Request<Body>,
|
|
next: Next,
|
|
) -> Response {
|
|
let principal: Option<Principal> = match extract_token(&req) {
|
|
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
|
|
None => None,
|
|
};
|
|
req.extensions_mut().insert(principal);
|
|
next.run(req).await
|
|
}
|
|
|
|
/// Decide whether the token is an API key (pic_ prefix) or a session
|
|
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
|
/// means the token was structurally valid but didn't match any active
|
|
/// credential; `Err(InternalError)` means a DB blip.
|
|
async fn resolve_principal(
|
|
state: &AuthState,
|
|
token: &str,
|
|
) -> Result<Option<Principal>, InternalError> {
|
|
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
|
|
return verify_api_key(state, rest).await;
|
|
}
|
|
verify_session(state, token).await
|
|
}
|
|
|
|
async fn verify_session(
|
|
state: &AuthState,
|
|
token: &str,
|
|
) -> Result<Option<Principal>, InternalError> {
|
|
let token_hash = hash_token(token);
|
|
|
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
|
Ok(Some(l)) => l,
|
|
Ok(None) => return Ok(None),
|
|
Err(err) => {
|
|
tracing::error!(?err, "admin_sessions lookup failed");
|
|
return Err(InternalError);
|
|
}
|
|
};
|
|
|
|
let user = match state.users.get(lookup.user_id).await {
|
|
Ok(Some(u)) if u.is_active => u,
|
|
Ok(_) => return Ok(None),
|
|
Err(err) => {
|
|
tracing::error!(?err, "admin_users lookup failed");
|
|
return Err(InternalError);
|
|
}
|
|
};
|
|
|
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
|
// than silent stale sessions. Same shape as Phase 3a.
|
|
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
|
tracing::error!(?err, "admin_sessions touch failed");
|
|
return Err(InternalError);
|
|
}
|
|
|
|
Ok(Some(Principal {
|
|
user_id: user.id,
|
|
instance_role: user.instance_role,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}))
|
|
}
|
|
|
|
/// API-key verification path. `rest` is the portion of the bearer
|
|
/// value *after* `pic_`. We slice off the first 8 chars as the
|
|
/// indexed lookup key, then Argon2id-verify each candidate's hash
|
|
/// against the full `rest`. At most one match is expected; multiple
|
|
/// candidates with the same prefix is statistically negligible but
|
|
/// handled correctly (verify each, take the first match).
|
|
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
|
return Ok(None);
|
|
}
|
|
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
|
|
|
let candidates = match state.keys.find_active_by_prefix(prefix).await {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
tracing::error!(?err, "api_keys lookup failed");
|
|
return Err(InternalError);
|
|
}
|
|
};
|
|
|
|
let matched: Option<ApiKeyVerification> = candidates
|
|
.into_iter()
|
|
.find(|c| verify_password(&c.hash, rest));
|
|
let Some(matched) = matched else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// Resolve the owning user. is_active = false → reject even if the
|
|
// key itself hasn't been expired yet (the expire_all_for_user
|
|
// cascade on deactivation is the primary defense; this is the
|
|
// belt-and-suspenders check at request time).
|
|
let user = match state.users.get(matched.user_id).await {
|
|
Ok(Some(u)) if u.is_active => u,
|
|
Ok(_) => return Ok(None),
|
|
Err(err) => {
|
|
tracing::error!(?err, "admin_users lookup for api key failed");
|
|
return Err(InternalError);
|
|
}
|
|
};
|
|
|
|
if let Err(err) = state.keys.touch_last_used(matched.id).await {
|
|
tracing::error!(?err, "api_keys touch_last_used failed");
|
|
// Soft-fail: a timestamp blip should not invalidate the
|
|
// request. Continue with the resolved Principal.
|
|
}
|
|
|
|
Ok(Some(Principal {
|
|
user_id: user.id,
|
|
instance_role: user.instance_role,
|
|
scopes: Some(matched.scopes),
|
|
app_binding: matched.app_id,
|
|
}))
|
|
}
|
|
|
|
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
|
|
/// Returns `None` on DB error (the caller treats `None` as "skip the
|
|
/// legacy extension"). New handlers use `Principal` and don't depend
|
|
/// on this.
|
|
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
|
match state.users.get(id).await {
|
|
Ok(Some(u)) => Some(u.username),
|
|
Ok(None) => None,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
?err,
|
|
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
|
/// or the `picloud_session` cookie (fallback for browser clients).
|
|
/// Same shape as Phase 3a; the cookie only ever carries session
|
|
/// tokens — no `pic_` prefix expected there.
|
|
fn extract_token(req: &Request<Body>) -> Option<String> {
|
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
|
if let Ok(s) = value.to_str() {
|
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
|
let trimmed = token.trim();
|
|
if !trimmed.is_empty() {
|
|
return Some(trimmed.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(value) = req.headers().get(header::COOKIE) {
|
|
if let Ok(s) = value.to_str() {
|
|
for chunk in s.split(';') {
|
|
let chunk = chunk.trim();
|
|
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
|
if !rest.is_empty() {
|
|
return Some(rest.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Sentinel returned from the resolve functions when a DB error should
|
|
/// produce a 500 rather than a 401. Empty struct because the actual
|
|
/// error is already logged at the failure site.
|
|
struct InternalError;
|
|
|
|
fn unauthorized() -> Response {
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({ "error": "authentication required" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn internal_error() -> Response {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({ "error": "internal error" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::http::Request;
|
|
use picloud_shared::InstanceRole;
|
|
|
|
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.header(name, value)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_bearer_token() {
|
|
let r = req_with_header("authorization", "Bearer abc123");
|
|
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_bearer_pic_prefixed_token() {
|
|
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
|
|
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_bearer_with_no_token() {
|
|
let r = req_with_header("authorization", "Bearer ");
|
|
assert_eq!(extract_token(&r), None);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_cookie_token() {
|
|
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
|
|
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
|
|
}
|
|
|
|
#[test]
|
|
fn bearer_wins_over_cookie() {
|
|
let r = Request::builder()
|
|
.header("authorization", "Bearer header-token")
|
|
.header("cookie", "picloud_session=cookie-token")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
|
|
}
|
|
|
|
#[test]
|
|
fn returns_none_when_neither_present() {
|
|
let r = Request::builder().body(Body::empty()).unwrap();
|
|
assert_eq!(extract_token(&r), None);
|
|
}
|
|
|
|
// Round-trip test for the unused-variable to keep `Principal`
|
|
// visibly tied to InstanceRole — caught a real bug during dev when
|
|
// the field order in the struct literal had drifted.
|
|
#[test]
|
|
fn principal_construction_is_explicit() {
|
|
let p = Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Owner,
|
|
scopes: None,
|
|
app_binding: None,
|
|
};
|
|
assert_eq!(p.instance_role, InstanceRole::Owner);
|
|
assert!(p.scopes.is_none());
|
|
assert!(p.app_binding.is_none());
|
|
}
|
|
}
|