feat: add app-level authentication foundation

This commit is contained in:
a.tolmachev
2026-03-30 23:47:09 +03:00
parent 91854a4153
commit ab2e603997
42 changed files with 1624 additions and 236 deletions
+198 -55
View File
@@ -1,9 +1,10 @@
use axum::{
Router,
Router, middleware,
routing::{delete, get, post},
};
use crate::{
auth::{require_session, require_workspace_session},
routes::{
access::{
create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key,
@@ -13,6 +14,7 @@ use crate::{
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
save_agent_bindings, update_agent,
},
auth::{get_session, login, logout},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
operations::{
@@ -115,16 +117,35 @@ pub fn build_app(state: AppState) -> Router {
.route("/usage/operations/{operation_id}", get(get_operation_usage))
.route("/usage/agents/{agent_id}", get(get_agent_usage));
let admin_router = Router::new()
let workspace_root_router = Router::new()
.route("/workspaces", get(list_workspaces).post(create_workspace))
.layer(middleware::from_fn_with_state(
state.clone(),
require_session,
));
let workspace_scoped_router = Router::new()
.route(
"/workspaces/{workspace_id}",
get(get_workspace).patch(update_workspace),
)
.nest("/workspaces/{workspace_id}", workspace_router);
.nest("/workspaces/{workspace_id}", workspace_router)
.layer(middleware::from_fn_with_state(
state.clone(),
require_workspace_session,
));
let admin_router = workspace_root_router.merge(workspace_scoped_router);
Router::new()
.route("/health", get(crate::routes::health))
.nest(
"/api/auth",
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/session", get(get_session)),
)
.nest("/api/admin", admin_router)
.with_state(state)
}
@@ -135,7 +156,7 @@ use crate::routes::operations::import_operation;
mod tests {
use std::{
collections::BTreeMap,
env,
env, fmt,
time::{SystemTime, UNIX_EPOCH},
};
@@ -143,30 +164,71 @@ mod tests {
use crank_adapter_grpc::test_support as grpc_test_support;
use crank_core::{
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
Protocol, RestTarget, Target, ToolDescription,
MembershipRole, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry;
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
use sqlx::{Executor, postgres::PgPoolOptions};
use serial_test::serial;
use sqlx::{Connection, Executor, PgConnection};
use tokio::net::TcpListener;
use crate::{
app::build_app,
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
service::{AdminService, OperationPayload},
state::AppState,
};
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
const TEST_AUTH_PASSWORD: &str = "test-password";
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
const TEST_SESSION_SECRET: &str = "test-session-secret";
#[tokio::test]
struct TestServer {
base_url: String,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
handle: Option<tokio::task::JoinHandle<()>>,
}
impl Drop for TestServer {
fn drop(&mut self) {
let shutdown = self.shutdown.take();
let handle = self.handle.take();
tokio::task::block_in_place(|| {
if let Some(shutdown) = shutdown {
let _ = shutdown.send(());
}
if let Some(handle) = handle {
let _ = tokio::runtime::Handle::current().block_on(handle);
}
});
}
}
impl fmt::Display for TestServer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.base_url)
}
}
impl AsRef<str> for TestServer {
fn as_ref(&self) -> &str {
&self.base_url
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_publishes_and_tests_rest_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("lifecycle");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -227,13 +289,14 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn updates_archives_and_deletes_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation_mutations");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -376,13 +439,14 @@ mod tests {
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_binds_and_publishes_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_lifecycle");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let operation = client
.post(format!("{base_url}/operations"))
@@ -392,10 +456,8 @@ mod tests {
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation = assert_success_json(operation).await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
@@ -456,13 +518,14 @@ mod tests {
assert_eq!(published["published_version"], 1);
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn updates_lists_and_deletes_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_mutations");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let operation = client
.post(format!("{base_url}/operations"))
@@ -472,10 +535,8 @@ mod tests {
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation = assert_success_json(operation).await;
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
@@ -587,12 +648,13 @@ mod tests {
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_platform_access_resources() {
let registry = test_registry().await;
let storage_root = test_storage_root("platform_access");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let members = client
.get(format!("{base_url}/members"))
@@ -694,13 +756,14 @@ mod tests {
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn exposes_logs_and_usage_from_real_test_runs() {
let registry = test_registry().await;
let storage_root = test_storage_root("observability");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -774,13 +837,14 @@ mod tests {
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_publishes_and_tests_graphql_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("graphql");
let upstream_base_url = spawn_graphql_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -827,13 +891,14 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn uploads_descriptor_set_and_lists_grpc_services() {
let registry = test_registry().await;
let storage_root = test_storage_root("grpc_descriptor");
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -879,13 +944,14 @@ mod tests {
assert_eq!(services["services"][0]["methods"][0]["kind"], "unary");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn creates_publishes_and_tests_grpc_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("grpc_runtime");
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -926,13 +992,14 @@ mod tests {
assert_eq!(test_run["response_preview"]["message"], "hello");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_auth_profiles_and_yaml_upsert() {
let registry = test_registry().await;
let storage_root = test_storage_root("yaml");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let auth_profile = client
.post(format!("{base_url}/auth-profiles"))
@@ -989,13 +1056,14 @@ mod tests {
assert_eq!(imported["import_mode"], "upsert");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn roundtrips_graphql_operation_through_yaml_upsert() {
let registry = test_registry().await;
let storage_root = test_storage_root("yaml_graphql");
let upstream_base_url = spawn_graphql_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -1047,13 +1115,14 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn roundtrips_grpc_operation_through_yaml_upsert() {
let registry = test_registry().await;
let storage_root = test_storage_root("yaml_grpc");
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -1102,13 +1171,14 @@ mod tests {
assert_eq!(test_run["response_preview"]["message"], "hello");
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn uploads_samples_and_generates_draft() {
let registry = test_registry().await;
let storage_root = test_storage_root("draft");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
@@ -1168,7 +1238,7 @@ mod tests {
fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router {
build_app(AppState {
service: AdminService::new(registry, storage_root),
service: AdminService::new(registry, storage_root, test_auth_settings()),
})
}
@@ -1196,18 +1266,66 @@ mod tests {
format!("http://{}", address)
}
async fn spawn_admin_api(app: Router) -> String {
async fn spawn_admin_api(app: Router) -> TestServer {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await
.unwrap();
});
format!(
"http://{}/api/admin/workspaces/{}",
address, DEFAULT_WORKSPACE_ID
)
TestServer {
base_url: format!(
"http://{}/api/admin/workspaces/{}",
address, DEFAULT_WORKSPACE_ID
),
shutdown: Some(shutdown_tx),
handle: Some(handle),
}
}
async fn authorized_client(workspace_base_url: impl AsRef<str>) -> reqwest::Client {
let root_url = workspace_base_url
.as_ref()
.split("/api/admin/workspaces/")
.next()
.unwrap();
let client = reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap();
client
.post(format!("{root_url}/api/auth/login"))
.json(&json!({
"email": TEST_AUTH_EMAIL,
"password": TEST_AUTH_PASSWORD,
}))
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
client
}
async fn assert_success_json(response: reqwest::Response) -> Value {
let status = response.status();
let body = response.text().await.unwrap();
assert!(
status.is_success(),
"request failed with status {status}: {body}"
);
serde_json::from_str(&body).unwrap()
}
async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
@@ -1239,11 +1357,7 @@ mod tests {
async fn test_registry() -> PostgresRegistry {
let database_url = env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:5432/crank".to_owned());
let admin_pool = PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.unwrap();
let mut admin_connection = PgConnection::connect(&database_url).await.unwrap();
let schema = format!(
"test_admin_api_{}_{}",
std::process::id(),
@@ -1253,14 +1367,29 @@ mod tests {
.as_nanos()
);
admin_pool
admin_connection
.execute(sqlx::query(&format!("create schema {schema}")))
.await
.unwrap();
PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}"))
let registry =
PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}"))
.await
.unwrap();
let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap();
let user_id = registry
.upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash)
.await
.unwrap()
.unwrap();
registry
.ensure_membership(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&user_id,
MembershipRole::Owner,
)
.await
.unwrap();
registry
}
fn test_storage_root(name: &str) -> std::path::PathBuf {
@@ -1274,6 +1403,20 @@ mod tests {
))
}
fn test_auth_settings() -> AuthSettings {
AuthSettings {
session_secret: TEST_SESSION_SECRET.to_owned(),
password_pepper: TEST_PASSWORD_PEPPER.to_owned(),
session_ttl_hours: 24,
cookie_secure: false,
bootstrap_admin: BootstrapAdminConfig {
email: TEST_AUTH_EMAIL.to_owned(),
password: TEST_AUTH_PASSWORD.to_owned(),
display_name: "Test Owner".to_owned(),
},
}
}
fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload {
OperationPayload {
name: name.to_owned(),