feat(manager-core,picloud): bearer pic_ keys land in Principal
* auth_middleware: split into resolve_principal → verify_session OR verify_api_key (selected by the pic_ prefix). Both paths converge on Principal as the request extension; require_admin keeps working as a #[deprecated] alias for require_authenticated. AuthState gains an api_keys repo; the cookie path is unchanged. * api-key path takes the first 8 chars after pic_ as the indexed lookup key, Argon2-verifies each candidate, soft-rejects deactivated users, and updates last_used_at inline. * auth_api: /auth/me now consumes Extension<Principal> and re-fetches the user row so username updates surface immediately. * picloud: AuthDeps + AuthState wired with PostgresApiKeyRepository; the layer call switches to require_authenticated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,10 @@ use picloud_shared::AdminUserId;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use picloud_shared::Principal;
|
||||||
|
|
||||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||||
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
|
||||||
|
|
||||||
pub fn auth_router(state: AuthState) -> Router {
|
pub fn auth_router(state: AuthState) -> Router {
|
||||||
// /login + /logout are unguarded (login is how you get in; logout
|
// /login + /logout are unguarded (login is how you get in; logout
|
||||||
@@ -31,7 +33,7 @@ pub fn auth_router(state: AuthState) -> Router {
|
|||||||
// who you are, so the middleware must run first.
|
// who you are, so the middleware must run first.
|
||||||
let guarded = Router::new()
|
let guarded = Router::new()
|
||||||
.route("/auth/me", get(me))
|
.route("/auth/me", get(me))
|
||||||
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
.route_layer(from_fn_with_state(state.clone(), require_authenticated));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/auth/login", post(login))
|
.route("/auth/login", post(login))
|
||||||
@@ -158,11 +160,22 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
|
|||||||
(StatusCode::NO_CONTENT, headers).into_response()
|
(StatusCode::NO_CONTENT, headers).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
async fn me(State(state): State<AuthState>, Extension(principal): Extension<Principal>) -> Response {
|
||||||
Json(AdminUserDto {
|
// /me consumes the resolved Principal directly; we re-fetch the
|
||||||
id: admin.id,
|
// user row only to surface a fresh username (it can change via
|
||||||
username: admin.username,
|
// PATCH while a session/key is still valid).
|
||||||
})
|
match state.users.get(principal.user_id).await {
|
||||||
|
Ok(Some(row)) => Json(AdminUserDto {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||||
|
internal_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
//! `require_admin` axum middleware: gates a router on a valid admin
|
//! Authentication middleware — resolves the caller's `Principal` from
|
||||||
//! session. Accepts the token from either the `picloud_session` cookie
|
//! either a session cookie / Bearer session-token OR an API key
|
||||||
//! or an `Authorization: Bearer …` header — same token system serves
|
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
||||||
//! the dashboard and CLI/CI clients.
|
//! request extension so downstream handlers see one shape.
|
||||||
//!
|
//!
|
||||||
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
//! Capability checks live in `crate::authz` and are called per-handler
|
||||||
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
//! (after the relevant resource is loaded, so the capability binds to
|
||||||
//! returns 401 with a generic JSON body (no enumeration about whether
|
//! the actual resource's `app_id`). This middleware is gate-only: it
|
||||||
//! the token was wrong vs. the user was deactivated).
|
//! 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::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
|
|||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, Principal};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::admin_session_repo::AdminSessionRepository;
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
use crate::admin_user_repo::AdminUserRepository;
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
use crate::auth::hash_token;
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
||||||
|
use crate::auth::{hash_token, verify_password};
|
||||||
|
|
||||||
pub const SESSION_COOKIE: &str = "picloud_session";
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||||
|
|
||||||
/// Shared state for auth: the two repos plus the configured sliding
|
/// Prefix on the wire that selects the API-key path. The body that
|
||||||
/// session TTL. Cheap to clone (`Arc` everywhere).
|
/// 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)]
|
#[derive(Clone)]
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
pub users: Arc<dyn AdminUserRepository>,
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request-extension type that authenticated handlers extract via
|
/// Legacy request-extension alias retained so the (only remaining)
|
||||||
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthedAdmin {
|
pub struct AuthedAdmin {
|
||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware function. Wire with
|
/// Middleware entry point. Wire with
|
||||||
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
||||||
pub async fn require_admin(
|
/// 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>,
|
State(state): State<AuthState>,
|
||||||
mut req: Request<Body>,
|
mut req: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
@@ -53,48 +74,166 @@ pub async fn require_admin(
|
|||||||
let Some(token) = extract_token(&req) else {
|
let Some(token) = extract_token(&req) else {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
};
|
};
|
||||||
let token_hash = hash_token(&token);
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||||
Ok(Some(lookup)) => lookup,
|
Ok(Some(l)) => l,
|
||||||
Ok(None) => return unauthorized(),
|
Ok(None) => return Ok(None),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(?err, "admin_sessions lookup failed");
|
tracing::error!(?err, "admin_sessions lookup failed");
|
||||||
return internal_error();
|
return Err(InternalError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the user. A deleted user is impossible here (FK cascade
|
|
||||||
// wipes their sessions), but a deactivated user still needs to be
|
|
||||||
// rejected — and so does the edge case of a session predating the
|
|
||||||
// deactivate (we wipe their sessions on deactivate, but a race
|
|
||||||
// could land a request in flight).
|
|
||||||
let user = match state.users.get(lookup.user_id).await {
|
let user = match state.users.get(lookup.user_id).await {
|
||||||
Ok(Some(u)) if u.is_active => u,
|
Ok(Some(u)) if u.is_active => u,
|
||||||
Ok(_) => return unauthorized(),
|
Ok(_) => return Ok(None),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(?err, "admin_users lookup failed");
|
tracing::error!(?err, "admin_users lookup failed");
|
||||||
return internal_error();
|
return Err(InternalError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||||
// surfaces as a request error rather than silent stale sessions.
|
// than silent stale sessions. Same shape as Phase 3a.
|
||||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
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 {
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
tracing::error!(?err, "admin_sessions touch failed");
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
return internal_error();
|
return Err(InternalError);
|
||||||
}
|
}
|
||||||
|
|
||||||
req.extensions_mut().insert(AuthedAdmin {
|
Ok(Some(Principal {
|
||||||
id: user.id,
|
user_id: user.id,
|
||||||
username: user.username,
|
instance_role: user.instance_role,
|
||||||
});
|
scopes: None,
|
||||||
next.run(req).await
|
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)
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||||
/// or the `picloud_session` cookie (fallback for browser clients).
|
/// 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> {
|
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||||
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
if let Ok(s) = value.to_str() {
|
if let Ok(s) = value.to_str() {
|
||||||
@@ -121,6 +260,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
|
|||||||
None
|
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 {
|
fn unauthorized() -> Response {
|
||||||
(
|
(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
@@ -141,6 +285,7 @@ fn internal_error() -> Response {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
@@ -155,6 +300,12 @@ mod tests {
|
|||||||
assert_eq!(extract_token(&r).as_deref(), Some("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]
|
#[test]
|
||||||
fn ignores_bearer_with_no_token() {
|
fn ignores_bearer_with_no_token() {
|
||||||
let r = req_with_header("authorization", "Bearer ");
|
let r = req_with_header("authorization", "Bearer ");
|
||||||
@@ -182,4 +333,20 @@ mod tests {
|
|||||||
let r = Request::builder().body(Body::empty()).unwrap();
|
let r = Request::builder().body(Body::empty()).unwrap();
|
||||||
assert_eq!(extract_token(&r), None);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ pub use auth_api::auth_router;
|
|||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||||
};
|
};
|
||||||
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
#[allow(deprecated)]
|
||||||
|
pub use auth_middleware::{
|
||||||
|
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
|
||||||
|
API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||||
|
};
|
||||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
pub use repo::{
|
pub use repo::{
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ 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, apps_api, apps_router, auth_router, compile_routes, migrations,
|
||||||
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
|
require_authenticated, route_admin_router, AdminSessionRepository, AdminState,
|
||||||
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState,
|
AdminUserRepository, AdminsState, ApiKeyRepository, AppDomainRepository, AppRepository,
|
||||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository,
|
AppsState, AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppRepository,
|
||||||
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||||
RouteRepository, SandboxCeiling,
|
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::{
|
||||||
@@ -37,6 +37,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
|||||||
pub struct AuthDeps {
|
pub struct AuthDeps {
|
||||||
pub users: Arc<dyn AdminUserRepository>,
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ impl AuthDeps {
|
|||||||
pub fn from_pool(pool: PgPool) -> Self {
|
pub fn from_pool(pool: PgPool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
|
||||||
|
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
|
||||||
ttl: read_session_ttl(),
|
ttl: read_session_ttl(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +148,7 @@ 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,
|
||||||
ttl: auth.ttl,
|
ttl: auth.ttl,
|
||||||
};
|
};
|
||||||
let admins_state = AdminsState {
|
let admins_state = AdminsState {
|
||||||
@@ -156,13 +159,18 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
// /admin/auth/login + /logout are unguarded by design (login is how
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||||
// you get in). /admin/auth/me applies the middleware internally so
|
// you get in). /admin/auth/me applies the middleware internally so
|
||||||
// the same Router::with_state machinery composes cleanly. Everything
|
// the same Router::with_state machinery composes cleanly. Everything
|
||||||
// else under /admin gets the require_admin layer.
|
// else under /admin gets the require_authenticated layer; capability
|
||||||
|
// checks live in each handler (after the resource is loaded so the
|
||||||
|
// capability binds to the resource's actual app_id).
|
||||||
let guarded_admin = Router::new()
|
let guarded_admin = Router::new()
|
||||||
.merge(admin_router(admin))
|
.merge(admin_router(admin))
|
||||||
.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))
|
||||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
.layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
require_authenticated,
|
||||||
|
));
|
||||||
|
|
||||||
// Silence "unused import" lint on `apps_api` — we re-export via the
|
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||||
// facade above; the bare module path is retained so it's discoverable.
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
|
|||||||
Reference in New Issue
Block a user