From ab2e603997042ad85f13b80cda62d97ca7f00f6f Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 23:47:09 +0300 Subject: [PATCH] feat: add app-level authentication foundation --- .env.example | 6 + Cargo.lock | 180 ++++++++++++++ Cargo.toml | 5 +- TASKS.md | 15 +- apps/admin-api/Cargo.toml | 4 + apps/admin-api/src/app.rs | 253 +++++++++++++++----- apps/admin-api/src/auth.rs | 193 +++++++++++++++ apps/admin-api/src/error.rs | 24 +- apps/admin-api/src/main.rs | 29 ++- apps/admin-api/src/routes.rs | 1 + apps/admin-api/src/routes/auth.rs | 52 ++++ apps/admin-api/src/routes/workspaces.rs | 19 +- apps/admin-api/src/service.rs | 184 +++++++++++++- apps/ui/html/agents.html | 5 +- apps/ui/html/api-keys.html | 5 +- apps/ui/html/login.html | 2 + apps/ui/html/logs.html | 3 +- apps/ui/html/settings.html | 3 +- apps/ui/html/usage.html | 3 +- apps/ui/html/wizard/index.html | 3 +- apps/ui/html/workspace-setup.html | 3 +- apps/ui/index.html | 3 +- apps/ui/js/api.js | 34 ++- apps/ui/js/auth.js | 143 +++++++++++ apps/ui/js/catalog.js | 3 +- apps/ui/js/login.js | 72 +++--- apps/ui/js/nav.js | 116 ++++----- crates/crank-core/src/ids.rs | 1 + crates/crank-core/src/lib.rs | 2 +- crates/crank-registry/Cargo.toml | 1 + crates/crank-registry/src/lib.rs | 9 +- crates/crank-registry/src/migrations.rs | 19 ++ crates/crank-registry/src/model.rs | 21 +- crates/crank-registry/src/postgres.rs | 306 ++++++++++++++++++++++-- docs/admin-api.md | 26 +- docs/alpine-ui-integration-plan.md | 28 +-- docs/architecture.md | 2 + docs/as-is-to-be.md | 17 +- docs/data-model.md | 33 ++- docs/database-schema.md | 16 ++ docs/deployment.md | 2 +- docs/runtime-config.md | 14 +- 42 files changed, 1624 insertions(+), 236 deletions(-) create mode 100644 apps/admin-api/src/auth.rs create mode 100644 apps/admin-api/src/routes/auth.rs create mode 100644 apps/ui/js/auth.js diff --git a/.env.example b/.env.example index 2a75b00..b4c4775 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,11 @@ CRANK_MCP_BIND=0.0.0.0:3002 CRANK_MCP_REFRESH_MS=5000 CRANK_LOG_LEVEL=info CRANK_SECRET_PROVIDER=env +CRANK_SESSION_SECRET=change-me-session-secret +CRANK_PASSWORD_PEPPER=change-me-password-pepper +CRANK_SESSION_TTL_HOURS=24 +CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local +CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password +CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner CRANK_PUBLIC_BASE_URL=https://crank.example.com CRANK_MCP_PUBLIC_URL=https://crank.example.com/mcp diff --git a/Cargo.lock b/Cargo.lock index 1ecccfd..b9d4a04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,7 +6,9 @@ version = 4 name = "admin-api" version = "0.1.0" dependencies = [ + "argon2", "axum", + "axum-extra", "base64", "crank-adapter-grpc", "crank-core", @@ -15,10 +17,12 @@ dependencies = [ "crank-registry", "crank-runtime", "crank-schema", + "rand 0.8.5", "reqwest", "serde", "serde_json", "serde_yaml", + "serial_test", "sha2", "sqlx", "thiserror", @@ -50,6 +54,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -134,18 +150,56 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -204,6 +258,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -306,6 +389,7 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "uuid", ] [[package]] @@ -407,6 +491,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -515,6 +608,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-intrusive" version = "0.5.0" @@ -1017,6 +1121,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1182,6 +1292,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1410,6 +1531,22 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" @@ -1620,6 +1757,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-core", "http", "http-body", @@ -1730,12 +1869,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -1831,6 +1985,32 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index d2eefb7..d3af950 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,13 +21,16 @@ rust-version = "1.85" version = "0.1.0" [workspace.dependencies] +argon2 = "0.5" axum = "0.8" +axum-extra = { version = "0.10", features = ["cookie"] } base64 = "0.22" prost = "0.14" prost-reflect = { version = "0.16", features = ["serde"] } prost-types = "0.14" protoc-bin-vendored = "3" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/TASKS.md b/TASKS.md index 30a6dc4..69acae0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,23 @@ ## Current -### `feat/alpine-settings-workspace` +### `feat/auth-foundation` Status: completed DoD: -- `Workspace setup` page использует live create/edit workspace flow через `admin-api` -- список workspace обновляется после create/edit без локальных моков -- members и invitations читаются из live backend -- create workspace умеет отправлять initial invites +- `admin-api` использует app-level auth вместо внешнего `Basic Auth` +- пользователи логинятся через email/password и `HttpOnly` session cookie +- пароли хранятся как `Argon2id` hash +- session secret и password pepper читаются из `.env` +- bootstrap admin user создается из env при старте +- `apps/ui/html/login.html` и `apps/ui/js/login.js` подключены к live auth flow +- защищенные UI-страницы проверяют session через backend - `test-ui` остается нетронутым как fallback ## Next -- `feat/alpine-login-auth-gap` +- `feat/alpine-polish` ## Backlog diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 0652b7f..7aa6980 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -6,7 +6,9 @@ rust-version.workspace = true version.workspace = true [dependencies] +argon2.workspace = true axum.workspace = true +axum-extra.workspace = true base64.workspace = true crank-core = { path = "../../crates/crank-core" } crank-mapping = { path = "../../crates/crank-mapping" } @@ -14,6 +16,7 @@ crank-proto = { path = "../../crates/crank-proto" } crank-registry = { path = "../../crates/crank-registry" } crank-runtime = { path = "../../crates/crank-runtime" } crank-schema = { path = "../../crates/crank-schema" } +rand.workspace = true serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true @@ -28,4 +31,5 @@ uuid.workspace = true [dev-dependencies] crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] } reqwest.workspace = true +serial_test = "3" sqlx.workspace = true diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 914e417..26c99af 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -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>, + handle: Option>, + } + + 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 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::() - .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::() - .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) -> 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) -> Json { @@ -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(), diff --git a/apps/admin-api/src/auth.rs b/apps/admin-api/src/auth.rs new file mode 100644 index 0000000..262ff21 --- /dev/null +++ b/apps/admin-api/src/auth.rs @@ -0,0 +1,193 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; +use axum::{ + extract::{OriginalUri, Request, State}, + middleware::Next, + response::Response, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crank_core::{User, UserSessionId, WorkspaceId}; +use crank_registry::WorkspaceMembershipRecord; +use rand::RngCore; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use time::{Duration, OffsetDateTime}; + +use crate::{error::ApiError, state::AppState}; + +pub const SESSION_COOKIE_NAME: &str = "crank_session"; + +#[derive(Clone)] +pub struct BootstrapAdminConfig { + pub email: String, + pub password: String, + pub display_name: String, +} + +#[derive(Clone)] +pub struct AuthSettings { + pub session_secret: String, + pub password_pepper: String, + pub session_ttl_hours: i64, + pub cookie_secure: bool, + pub bootstrap_admin: BootstrapAdminConfig, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AuthenticatedSession { + pub user: User, + pub memberships: Vec, +} + +#[derive(Clone)] +pub struct SessionCookie { + pub session_id: UserSessionId, + pub value: String, + pub expires_at: String, +} + +pub fn hash_password(password: &str, pepper: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let password = format!("{password}{pepper}"); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| ApiError::internal(format!("failed to hash password: {error}"))) +} + +pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(password_hash) else { + return false; + }; + let password = format!("{password}{pepper}"); + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +pub fn create_session_cookie(settings: &AuthSettings) -> Result { + let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); + let mut secret_bytes = [0_u8; 32]; + rand::thread_rng().fill_bytes(&mut secret_bytes); + let secret = URL_SAFE_NO_PAD.encode(secret_bytes); + let expires_at = OffsetDateTime::now_utc() + .checked_add(Duration::hours(settings.session_ttl_hours)) + .ok_or_else(|| ApiError::internal("failed to compute session expiration"))?; + + Ok(SessionCookie { + session_id, + value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()), + expires_at: expires_at + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + ApiError::internal(format!("failed to format session expiration: {error}")) + })?, + }) +} + +pub fn hash_session_secret( + session_id: &UserSessionId, + session_value: &str, + session_secret: &str, +) -> String { + let mut digest = Sha256::new(); + digest.update(session_id.as_str().as_bytes()); + digest.update(b":"); + digest.update(session_value.as_bytes()); + digest.update(b":"); + digest.update(session_secret.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.finalize()) +} + +pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE_NAME, token.to_owned())) + .http_only(true) + .same_site(SameSite::Lax) + .secure(settings.cookie_secure) + .path("/") + .max_age(Duration::hours(settings.session_ttl_hours)) + .build() +} + +pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE_NAME, String::new())) + .http_only(true) + .same_site(SameSite::Lax) + .secure(settings.cookie_secure) + .path("/") + .max_age(Duration::seconds(0)) + .build() +} + +pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> { + let cookie = jar.get(SESSION_COOKIE_NAME)?; + let value = cookie.value(); + let (session_id, session_value) = value.split_once('.')?; + + Some((UserSessionId::new(session_id), session_value.to_owned())) +} + +pub async fn require_session( + State(state): State, + jar: CookieJar, + mut request: Request, + next: Next, +) -> Result { + let session = resolve_authenticated_session(state.clone(), &jar).await?; + request.extensions_mut().insert(session); + Ok(next.run(request).await) +} + +pub async fn require_workspace_session( + State(state): State, + jar: CookieJar, + original_uri: OriginalUri, + mut request: Request, + next: Next, +) -> Result { + let session = resolve_authenticated_session(state.clone(), &jar).await?; + let workspace_id = workspace_id_from_path(original_uri.path()) + .ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?; + + let has_access = state + .service + .user_has_workspace_access(&session.user.id, &workspace_id) + .await?; + if !has_access { + return Err(ApiError::forbidden("workspace access denied")); + } + + request.extensions_mut().insert(session); + Ok(next.run(request).await) +} + +async fn resolve_authenticated_session( + state: AppState, + jar: &CookieJar, +) -> Result { + let (session_id, session_value) = extract_session_token(jar) + .ok_or_else(|| ApiError::unauthorized("authentication required"))?; + let session = state + .service + .get_session(&session_id, &session_value) + .await? + .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; + + state.service.touch_session(&session_id).await?; + + Ok(session) +} + +fn workspace_id_from_path(path: &str) -> Option { + let marker = "/api/admin/workspaces/"; + let (_, suffix) = path.split_once(marker)?; + let workspace_id = suffix.split('/').next()?; + if workspace_id.is_empty() { + return None; + } + + Some(WorkspaceId::new(workspace_id)) +} diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 0ee380e..87db551 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -15,6 +15,10 @@ use crate::storage::StorageError; #[derive(Debug, Error)] pub enum ApiError { + #[error("{message}")] + Unauthorized { message: String }, + #[error("{message}")] + Forbidden { message: String }, #[error("{message}")] Validation { message: String }, #[error("{message}")] @@ -26,6 +30,18 @@ pub enum ApiError { } impl ApiError { + pub fn unauthorized(message: impl Into) -> Self { + Self::Unauthorized { + message: message.into(), + } + } + + pub fn forbidden(message: impl Into) -> Self { + Self::Forbidden { + message: message.into(), + } + } + pub fn validation(message: impl Into) -> Self { Self::Validation { message: message.into(), @@ -52,6 +68,8 @@ impl ApiError { fn status_code(&self) -> StatusCode { match self { + Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, + Self::Forbidden { .. } => StatusCode::FORBIDDEN, Self::Validation { .. } => StatusCode::BAD_REQUEST, Self::NotFound { .. } => StatusCode::NOT_FOUND, Self::Conflict { .. } => StatusCode::CONFLICT, @@ -61,6 +79,8 @@ impl ApiError { fn code(&self) -> &'static str { match self { + Self::Unauthorized { .. } => "unauthorized", + Self::Forbidden { .. } => "forbidden", Self::Validation { .. } => "validation_error", Self::NotFound { .. } => "not_found", Self::Conflict { .. } => "conflict", @@ -75,7 +95,9 @@ impl IntoResponse for ApiError { Self::Internal { message } => { error!(error_code = self.code(), error_message = %message) } - Self::Validation { message } + Self::Unauthorized { message } + | Self::Forbidden { message } + | Self::Validation { message } | Self::NotFound { message } | Self::Conflict { message } => { warn!(error_code = self.code(), error_message = %message) diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 9d52a44..738e131 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod auth; mod error; mod routes; mod service; @@ -11,7 +12,12 @@ use crank_registry::PostgresRegistry; use tokio::net::TcpListener; use tracing::info; -use crate::{app::build_app, service::AdminService, state::AppState}; +use crate::{ + app::build_app, + auth::{AuthSettings, BootstrapAdminConfig}, + service::AdminService, + state::AppState, +}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -26,11 +32,28 @@ async fn main() -> Result<(), Box> { env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()), ); let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into()); + let public_base_url = + env::var("CRANK_PUBLIC_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()); let socket_addr: SocketAddr = bind_addr.parse()?; let registry = PostgresRegistry::connect(&database_url).await?; - let state = AppState { - service: AdminService::new(registry, storage_root), + let auth_settings = AuthSettings { + session_secret: env::var("CRANK_SESSION_SECRET")?, + password_pepper: env::var("CRANK_PASSWORD_PEPPER")?, + session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(24), + cookie_secure: public_base_url.starts_with("https://"), + bootstrap_admin: BootstrapAdminConfig { + email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?, + password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?, + display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME") + .unwrap_or_else(|_| "Crank Owner".into()), + }, }; + let service = AdminService::new(registry, storage_root, auth_settings); + service.bootstrap_admin_user().await?; + let state = AppState { service }; let app = build_app(state); let listener = TcpListener::bind(socket_addr).await?; diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index 428b67d..2f2828d 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -1,5 +1,6 @@ pub mod access; pub mod agents; +pub mod auth; pub mod auth_profiles; pub mod observability; pub mod operations; diff --git a/apps/admin-api/src/routes/auth.rs b/apps/admin-api/src/routes/auth.rs new file mode 100644 index 0000000..141ea52 --- /dev/null +++ b/apps/admin-api/src/routes/auth.rs @@ -0,0 +1,52 @@ +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use axum_extra::extract::cookie::CookieJar; + +use crate::{ + auth::{cleared_session_cookie, extract_session_token, session_cookie}, + error::ApiError, + service::LoginPayload, + state::AppState, +}; + +pub async fn login( + State(state): State, + jar: CookieJar, + Json(payload): Json, +) -> Result { + let (session_data, session) = state.service.login(payload).await?; + let cookie_value = format!( + "{}.{}", + session_data.session_id.as_str(), + session_data.value + ); + let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value)); + + Ok((jar, Json(serde_json::json!(session)))) +} + +pub async fn logout( + State(state): State, + jar: CookieJar, +) -> Result { + if let Some((session_id, session_value)) = extract_session_token(&jar) { + state.service.logout(&session_id, &session_value).await?; + } + + let jar = jar.remove(cleared_session_cookie(state.service.auth_settings())); + Ok((jar, StatusCode::NO_CONTENT)) +} + +pub async fn get_session( + State(state): State, + jar: CookieJar, +) -> Result, ApiError> { + let (session_id, session_value) = extract_session_token(&jar) + .ok_or_else(|| ApiError::unauthorized("authentication required"))?; + let session = state + .service + .session_response(&session_id, &session_value) + .await? + .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; + + Ok(Json(serde_json::json!(session))) +} diff --git a/apps/admin-api/src/routes/workspaces.rs b/apps/admin-api/src/routes/workspaces.rs index dbb0e6a..f75cfb4 100644 --- a/apps/admin-api/src/routes/workspaces.rs +++ b/apps/admin-api/src/routes/workspaces.rs @@ -1,25 +1,36 @@ use axum::{ - Json, + Extension, Json, extract::{Path, State}, }; use serde_json::{Value, json}; use crate::{ + auth::AuthenticatedSession, error::ApiError, service::{UpdateWorkspacePayload, WorkspacePayload}, state::AppState, }; -pub async fn list_workspaces(State(state): State) -> Result, ApiError> { - let items = state.service.list_workspaces().await?; +pub async fn list_workspaces( + State(state): State, + Extension(session): Extension, +) -> Result, ApiError> { + let items = state + .service + .list_workspaces_for_user(&session.user.id) + .await?; Ok(Json(json!({ "items": items }))) } pub async fn create_workspace( State(state): State, + Extension(session): Extension, Json(payload): Json, ) -> Result, ApiError> { - let workspace = state.service.create_workspace(payload).await?; + let workspace = state + .service + .create_workspace(&session.user.id, payload) + .await?; Ok(Json(json!(workspace))) } diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index b59c991..107afd4 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -8,7 +8,8 @@ use crank_core::{ InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, - Protocol, SampleId, Samples, Target, UsagePeriod, Workspace, WorkspaceId, WorkspaceStatus, + Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId, + WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; @@ -21,7 +22,7 @@ use crank_registry::{ PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, - UsageSummary, UsageTimelinePoint, WorkspaceRecord, + UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_schema::Schema; @@ -32,13 +33,33 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::{info, instrument}; use uuid::Uuid; -use crate::{error::ApiError, storage::LocalArtifactStorage}; +use crate::{ + auth::{ + AuthSettings, AuthenticatedSession, SessionCookie, create_session_cookie, hash_password, + hash_session_secret, verify_password, + }, + error::ApiError, + storage::LocalArtifactStorage, +}; #[derive(Clone)] pub struct AdminService { registry: PostgresRegistry, runtime: RuntimeExecutor, storage: LocalArtifactStorage, + auth_settings: AuthSettings, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LoginPayload { + pub email: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct SessionResponse { + pub user: crank_core::User, + pub memberships: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -421,22 +442,151 @@ fn default_operation_category() -> String { } impl AdminService { - pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self { + pub fn new( + registry: PostgresRegistry, + storage_root: PathBuf, + auth_settings: AuthSettings, + ) -> Self { Self { registry, runtime: RuntimeExecutor::new(), storage: LocalArtifactStorage::new(storage_root), + auth_settings, } } - #[instrument(skip(self))] - pub async fn list_workspaces(&self) -> Result, ApiError> { - Ok(self.registry.list_workspaces().await?) + pub fn auth_settings(&self) -> &AuthSettings { + &self.auth_settings } - #[instrument(skip(self, payload), fields(workspace_slug = %payload.slug))] + pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> { + let password_hash = hash_password( + &self.auth_settings.bootstrap_admin.password, + &self.auth_settings.password_pepper, + )?; + let user_id = self + .registry + .upsert_bootstrap_user( + &self.auth_settings.bootstrap_admin.email, + &self.auth_settings.bootstrap_admin.display_name, + &password_hash, + ) + .await?; + self.registry + .ensure_membership( + &WorkspaceId::new("ws_default"), + &user_id, + MembershipRole::Owner, + ) + .await?; + + Ok(()) + } + + pub async fn get_session( + &self, + session_id: &UserSessionId, + session_value: &str, + ) -> Result, ApiError> { + let secret_hash = hash_session_secret( + session_id, + session_value, + &self.auth_settings.session_secret, + ); + let session = self + .registry + .get_user_session(session_id, &secret_hash) + .await? + .map(|record| AuthenticatedSession { + user: record.user, + memberships: record.memberships, + }); + + Ok(session) + } + + pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> { + self.registry.touch_user_session(session_id).await?; + Ok(()) + } + + pub async fn login( + &self, + payload: LoginPayload, + ) -> Result<(SessionCookie, SessionResponse), ApiError> { + let user = self + .registry + .get_auth_user_by_email(&payload.email) + .await? + .ok_or_else(|| ApiError::unauthorized("invalid email or password"))?; + + if !verify_password( + &payload.password, + &self.auth_settings.password_pepper, + &user.password_hash, + ) { + return Err(ApiError::unauthorized("invalid email or password")); + } + + let session_cookie = create_session_cookie(&self.auth_settings)?; + let secret_hash = hash_session_secret( + &session_cookie.session_id, + &session_cookie.value, + &self.auth_settings.session_secret, + ); + self.registry + .create_user_session( + &session_cookie.session_id, + &user.user.id, + &secret_hash, + &session_cookie.expires_at, + ) + .await?; + let memberships = self + .registry + .list_workspaces_for_user(&user.user.id) + .await?; + + Ok(( + session_cookie, + SessionResponse { + user: user.user, + memberships, + }, + )) + } + + pub async fn logout( + &self, + session_id: &UserSessionId, + _session_value: &str, + ) -> Result<(), ApiError> { + self.registry.revoke_user_session(session_id).await?; + Ok(()) + } + + pub async fn list_workspaces_for_user( + &self, + user_id: &crank_core::UserId, + ) -> Result, ApiError> { + Ok(self.registry.list_workspaces_for_user(user_id).await?) + } + + pub async fn user_has_workspace_access( + &self, + user_id: &crank_core::UserId, + workspace_id: &WorkspaceId, + ) -> Result { + Ok(self + .registry + .user_has_workspace_access(user_id, workspace_id) + .await?) + } + + #[instrument(skip(self, payload), fields(workspace_slug = %payload.slug, user_id = %user_id.as_str()))] pub async fn create_workspace( &self, + user_id: &crank_core::UserId, payload: WorkspacePayload, ) -> Result { let now = now_string()?; @@ -455,10 +605,28 @@ impl AdminService { workspace: &workspace, }) .await?; + self.registry + .ensure_membership(&workspace.id, user_id, MembershipRole::Owner) + .await?; Ok(WorkspaceRecord { workspace }) } + #[instrument(skip(self))] + pub async fn session_response( + &self, + session_id: &UserSessionId, + session_value: &str, + ) -> Result, ApiError> { + Ok(self + .get_session(session_id, session_value) + .await? + .map(|session| SessionResponse { + user: session.user, + memberships: session.memberships, + })) + } + pub async fn get_workspace( &self, workspace_id: &WorkspaceId, diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index 508d465..eec1581 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -11,11 +11,12 @@ - + + @@ -75,7 +76,7 @@ Settings - diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index f2e7350..edf3069 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -23,11 +23,12 @@ .scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); } .scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); } - - + + + diff --git a/apps/ui/html/login.html b/apps/ui/html/login.html index e987eab..bc63f13 100644 --- a/apps/ui/html/login.html +++ b/apps/ui/html/login.html @@ -10,6 +10,8 @@ + + diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html index 4752db5..d8937a1 100644 --- a/apps/ui/html/logs.html +++ b/apps/ui/html/logs.html @@ -10,11 +10,12 @@ - + + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index f651034..6090f1f 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -10,11 +10,12 @@ - + + diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html index 0970603..c653157 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -10,11 +10,12 @@ - + + diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 194cecc..8522f10 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -9,11 +9,12 @@ - + + diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 14ca06d..4374132 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -11,10 +11,11 @@ - + +
diff --git a/apps/ui/index.html b/apps/ui/index.html index 53f3358..59ab498 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -9,11 +9,12 @@ - + + diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index f8dd2be..b0d2160 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -1,5 +1,6 @@ (function() { var API_BASE = '/api/admin'; + var AUTH_BASE = '/api/auth'; function headers(extra) { return Object.assign({ @@ -8,7 +9,7 @@ } async function request(path, options) { - var response = await fetch(API_BASE + path, Object.assign({ + var response = await fetch(path, Object.assign({ credentials: 'same-origin', headers: headers(), }, options || {})); @@ -27,6 +28,9 @@ } if (!response.ok) { + if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') { + window.CrankAuth.handleUnauthorized(); + } var message = payload && payload.error ? payload.error.message : payload && payload.message @@ -44,11 +48,11 @@ } function get(path) { - return request(path); + return request(API_BASE + path); } function post(path, body) { - return request(path, { + return request(API_BASE + path, { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), @@ -56,7 +60,7 @@ } function patch(path, body) { - return request(path, { + return request(API_BASE + path, { method: 'PATCH', headers: headers({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), @@ -64,7 +68,7 @@ } function del(path) { - return request(path, { method: 'DELETE' }); + return request(API_BASE + path, { method: 'DELETE' }); } function query(params) { @@ -81,7 +85,7 @@ } function postBytes(path, bytes, fileName) { - return request(path, { + return request(API_BASE + path, { method: 'POST', headers: headers(fileName ? { 'X-File-Name': fileName } : {}), body: bytes, @@ -89,6 +93,23 @@ } window.CrankApi = { + login: function(payload) { + return request(AUTH_BASE + '/login', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + }, + logout: function() { + return request(AUTH_BASE + '/logout', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({}), + }); + }, + getSession: function() { + return request(AUTH_BASE + '/session'); + }, listWorkspaces: function() { return get('/workspaces'); }, @@ -197,4 +218,5 @@ ); }, }; + }()); diff --git a/apps/ui/js/auth.js b/apps/ui/js/auth.js new file mode 100644 index 0000000..a1a1ffc --- /dev/null +++ b/apps/ui/js/auth.js @@ -0,0 +1,143 @@ +(function() { + var STORAGE_KEY = 'crank_user'; + var sessionCache = null; + var sessionPromise = null; + + function isLoginPage() { + return /\/html\/login\.html$/.test(window.location.pathname); + } + + function loginUrl() { + return (window.APP_BASE || '') + 'html/login.html'; + } + + function homeUrl() { + return (window.APP_BASE || '') + 'index.html'; + } + + function clearUserMirror() { + sessionCache = null; + sessionPromise = null; + try { + localStorage.removeItem(STORAGE_KEY); + } catch (_error) {} + } + + function primaryMembership(session) { + return session && session.memberships && session.memberships.length + ? session.memberships[0] + : null; + } + + function initials(displayName, email) { + var source = displayName || email || 'Crank'; + return source + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(function(part) { return part.charAt(0).toUpperCase(); }) + .join('') || 'CR'; + } + + function persistUserMirror(session) { + var membership = primaryMembership(session); + var user = { + id: session.user.id, + name: session.user.display_name, + email: session.user.email, + role: membership ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); }) : 'Viewer', + workspace: membership ? membership.workspace.slug : '', + workspaceId: membership ? membership.workspace.id : '', + initials: initials(session.user.display_name, session.user.email), + }; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); + } catch (_error) {} + } + + async function fetchSession(force) { + if (!force && sessionCache) { + return sessionCache; + } + if (!force && sessionPromise) { + return sessionPromise; + } + + sessionPromise = window.CrankApi.getSession() + .then(function(session) { + sessionCache = session; + persistUserMirror(session); + return session; + }) + .catch(function(error) { + clearUserMirror(); + throw error; + }) + .finally(function() { + sessionPromise = null; + }); + + return sessionPromise; + } + + async function guardProtectedPage() { + try { + return await fetchSession(false); + } catch (error) { + if (error && error.status === 401) { + window.location.replace(loginUrl()); + return null; + } + throw error; + } + } + + async function guardLoginPage() { + try { + await fetchSession(false); + window.location.replace(homeUrl()); + } catch (error) { + if (!error || error.status !== 401) { + throw error; + } + } + } + + async function login(email, password) { + var session = await window.CrankApi.login({ + email: email, + password: password, + }); + sessionCache = session; + persistUserMirror(session); + window.location.href = homeUrl(); + } + + async function logout() { + try { + await window.CrankApi.logout(); + } finally { + clearUserMirror(); + window.location.href = loginUrl(); + } + } + + function handleUnauthorized() { + if (isLoginPage()) { + clearUserMirror(); + return; + } + clearUserMirror(); + window.location.replace(loginUrl()); + } + + window.CrankAuth = { + fetchSession: fetchSession, + guardProtectedPage: guardProtectedPage, + guardLoginPage: guardLoginPage, + login: login, + logout: logout, + handleUnauthorized: handleUnauthorized, + }; +}()); diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js index a510538..c3bbf16 100644 --- a/apps/ui/js/catalog.js +++ b/apps/ui/js/catalog.js @@ -423,8 +423,7 @@ document.addEventListener('alpine:init', function() { }, handleLogout() { - localStorage.removeItem('crank_user'); - window.location.href = (window.APP_BASE || '') + 'html/login.html'; + window.CrankAuth.logout(); }, }; }); diff --git a/apps/ui/js/login.js b/apps/ui/js/login.js index ca5e702..f40b400 100644 --- a/apps/ui/js/login.js +++ b/apps/ui/js/login.js @@ -1,34 +1,42 @@ -// If already logged in, redirect to home -try { - if (localStorage.getItem('crank_user')) { - window.location.replace((window.APP_BASE||'')+'index.html'); - } -} catch (e) {} - -document.getElementById('login-form').addEventListener('submit', function (e) { - e.preventDefault(); - var email = document.getElementById('email').value.trim(); - var password = document.getElementById('password').value; - var errEl = document.getElementById('login-error'); - - if (!email || !password) { - errEl.style.display = 'block'; - errEl.textContent = 'Please enter your email and password.'; - return; +(function() { + function showError(message) { + var errorElement = document.getElementById('login-error'); + errorElement.style.display = 'block'; + errorElement.textContent = message; } - // Demo: any non-empty credentials succeed - errEl.style.display = 'none'; - var initials = email.split('@')[0].slice(0, 2).toUpperCase(); - var displayName = email.split('@')[0].replace(/[._]/g, ' ') - .split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' '); - var user = { - name: displayName, - email: email, - role: 'Admin', - workspace: 'acme-workspace', - initials: initials, - }; - try { localStorage.setItem('crank_user', JSON.stringify(user)); } catch (ex) {} - window.location.href = (window.APP_BASE||'')+'index.html'; -}); + function hideError() { + var errorElement = document.getElementById('login-error'); + errorElement.style.display = 'none'; + } + + document.addEventListener('DOMContentLoaded', function() { + window.CrankAuth.guardLoginPage().catch(function(error) { + console.error(error); + }); + + document.getElementById('login-form').addEventListener('submit', async function(event) { + event.preventDefault(); + + var email = document.getElementById('email').value.trim(); + var password = document.getElementById('password').value; + + if (!email || !password) { + showError('Please enter your email and password.'); + return; + } + + hideError(); + + try { + await window.CrankAuth.login(email, password); + } catch (error) { + if (error && error.status === 401) { + showError('Invalid email or password. Please try again.'); + return; + } + showError('Unable to sign in right now. Please try again.'); + } + }); + }); +}()); diff --git a/apps/ui/js/nav.js b/apps/ui/js/nav.js index 3006136..50e544a 100644 --- a/apps/ui/js/nav.js +++ b/apps/ui/js/nav.js @@ -1,94 +1,99 @@ -/** - * nav.js — shared navbar logic for non-catalog pages - * Handles: auth guard, user dropdown, hamburger, logout - */ (function () { - - /* ── Auth guard ── */ var STORAGE_KEY = 'crank_user'; - var user = null; - try { user = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) {} - if (!user) { - var path = window.location.pathname; - var isLogin = path.endsWith('login.html') || path.endsWith('/login'); - if (!isLogin) { window.location.replace((window.APP_BASE||'')+'html/login.html'); return; } + function currentUser() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)); + } catch (_error) { + return null; + } } - /* ── Fill user info once DOM is available ── */ function fillUserInfo() { - if (!user) return; - document.querySelectorAll('.nav-avatar').forEach(function (el) { - el.textContent = user.initials || 'AT'; + var user = currentUser(); + if (!user) { + return; + } + + document.querySelectorAll('.nav-avatar').forEach(function (element) { + element.textContent = user.initials || 'CR'; }); - var nameEl = document.querySelector('.user-dropdown-name'); - var roleEl = document.querySelector('.user-dropdown-role'); - if (nameEl) nameEl.textContent = user.name || 'Operator'; - if (roleEl) roleEl.textContent = (user.role || 'Admin') + ' · ' + (user.workspace || 'workspace'); + + var nameElement = document.querySelector('.user-dropdown-name'); + var roleElement = document.querySelector('.user-dropdown-role'); + + if (nameElement) { + nameElement.textContent = user.name || 'Operator'; + } + + if (roleElement) { + roleElement.textContent = (user.role || 'Viewer') + ' · ' + (user.workspace || 'workspace'); + } } - /* ── Wire up nav interactions ── */ function initNav() { fillUserInfo(); - var dropdown = document.querySelector('.user-dropdown'); - var avatar = document.querySelector('.nav-avatar'); + var dropdown = document.querySelector('.user-dropdown'); + var avatar = document.querySelector('.nav-avatar'); var hamburger = document.querySelector('.nav-hamburger'); var mobileNav = document.querySelector('.mobile-nav'); - // Initially hide elements controlled by JS - if (dropdown) dropdown.style.display = 'none'; - if (mobileNav) mobileNav.style.display = 'none'; + if (dropdown) { + dropdown.style.display = 'none'; + } + if (mobileNav) { + mobileNav.style.display = 'none'; + } - // User dropdown toggle if (avatar && dropdown) { - avatar.addEventListener('click', function (e) { - e.stopPropagation(); - var showing = dropdown.style.display !== 'none'; - dropdown.style.display = showing ? 'none' : 'block'; + avatar.addEventListener('click', function (event) { + event.stopPropagation(); + dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }); } - // Hamburger toggle if (hamburger && mobileNav) { - hamburger.addEventListener('click', function (e) { - e.stopPropagation(); - var showing = mobileNav.style.display !== 'none'; - mobileNav.style.display = showing ? 'none' : 'block'; - hamburger.classList.toggle('open', !showing); + hamburger.addEventListener('click', function (event) { + event.stopPropagation(); + mobileNav.style.display = mobileNav.style.display === 'none' ? 'block' : 'none'; + hamburger.classList.toggle('open', mobileNav.style.display !== 'none'); }); } - // Close dropdown / mobile-nav on outside click - document.addEventListener('click', function (e) { - if (dropdown && !e.target.closest('.user-menu')) { + document.addEventListener('click', function (event) { + if (dropdown && !event.target.closest('.user-menu')) { dropdown.style.display = 'none'; } - if (mobileNav && !e.target.closest('.navbar') && !e.target.closest('.mobile-nav')) { + if (mobileNav && !event.target.closest('.navbar') && !event.target.closest('.mobile-nav')) { mobileNav.style.display = 'none'; - if (hamburger) hamburger.classList.remove('open'); + if (hamburger) { + hamburger.classList.remove('open'); + } } }); - // Action buttons inside dropdown - document.querySelectorAll('[data-action="logout"]').forEach(function (btn) { - btn.addEventListener('click', function () { - localStorage.removeItem(STORAGE_KEY); - window.location.href = (window.APP_BASE||'')+'html/login.html'; + document.querySelectorAll('[data-action="logout"]').forEach(function (button) { + button.addEventListener('click', function () { + window.CrankAuth.logout(); }); }); - document.querySelectorAll('[data-action="profile"]').forEach(function (btn) { - btn.addEventListener('click', function () { - if (dropdown) dropdown.style.display = 'none'; - window.location.href = (window.APP_BASE||'')+'html/settings.html#profile'; + document.querySelectorAll('[data-action="profile"]').forEach(function (button) { + button.addEventListener('click', function () { + if (dropdown) { + dropdown.style.display = 'none'; + } + window.location.href = (window.APP_BASE || '') + 'html/settings.html#profile'; }); }); - document.querySelectorAll('[data-action="settings-link"]').forEach(function (btn) { - btn.addEventListener('click', function () { - if (dropdown) dropdown.style.display = 'none'; - window.location.href = (window.APP_BASE||'')+'html/settings.html'; + document.querySelectorAll('[data-action="settings-link"]').forEach(function (button) { + button.addEventListener('click', function () { + if (dropdown) { + dropdown.style.display = 'none'; + } + window.location.href = (window.APP_BASE || '') + 'html/settings.html'; }); }); } @@ -98,5 +103,4 @@ } else { initNav(); } - })(); diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index ef7e652..604e255 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -42,6 +42,7 @@ define_id!(SampleId); define_id!(AuthProfileId); define_id!(WorkspaceId); define_id!(UserId); +define_id!(UserSessionId); define_id!(AgentId); define_id!(InvitationId); define_id!(PlatformApiKeyId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index f61f931..23cf570 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -18,7 +18,7 @@ pub use auth::{ }; pub use ids::{ AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, - PlatformApiKeyId, SampleId, ToolId, UserId, WorkspaceId, + PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId, }; pub use observability::{ InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup, diff --git a/crates/crank-registry/Cargo.toml b/crates/crank-registry/Cargo.toml index 9db9a95..2e94460 100644 --- a/crates/crank-registry/Cargo.toml +++ b/crates/crank-registry/Cargo.toml @@ -13,6 +13,7 @@ serde.workspace = true serde_json.workspace = true sqlx.workspace = true thiserror.workspace = true +uuid.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index 99278fd..fa9a8d3 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -5,7 +5,7 @@ mod postgres; pub use error::RegistryError; pub use model::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, + AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, @@ -13,8 +13,9 @@ pub use model::{ OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, - UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, - YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, + UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 12089e2..f24dfb4 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -20,6 +20,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { id text primary key, email text not null unique, display_name text not null, + password_hash text null, status text not null, created_at timestamptz not null )", @@ -27,6 +28,10 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + query("alter table users add column if not exists password_hash text null") + .execute(pool) + .await?; + query( "insert into users ( id, @@ -58,6 +63,20 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + query( + "create table if not exists user_sessions ( + id text primary key, + user_id text not null references users(id) on delete cascade, + secret_hash text not null, + status text not null, + expires_at timestamptz not null, + last_seen_at timestamptz null, + created_at timestamptz not null + )", + ) + .execute(pool) + .await?; + query( "insert into workspaces ( id, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index aa9fd09..d975e30 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -2,7 +2,7 @@ use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId, ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, UsagePeriod, - UsageRollup, User, Workspace, WorkspaceId, + UsageRollup, User, UserSessionId, Workspace, WorkspaceId, }; use crank_mapping::MappingSet; use crank_schema::Schema; @@ -55,6 +55,25 @@ pub struct MembershipRecord { pub created_at: String, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceMembershipRecord { + pub workspace: Workspace, + pub role: MembershipRole, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthUserRecord { + pub user: User, + pub password_hash: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SessionRecord { + pub session_id: UserSessionId, + pub user: User, + pub memberships: Vec, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct InvitationRecord { pub invitation: InvitationToken, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 4d5c33b..c981251 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -1,8 +1,8 @@ use crank_core::{ AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType, - HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, OperationId, - OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, UsageRollup, - User, UserId, Workspace, WorkspaceId, + HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, + OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, + UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -16,17 +16,18 @@ use crate::{ error::RegistryError, migrations, model::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, - CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, - InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, - OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, - PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, - RegistryOperation, SaveAgentBindingsRequest, SaveAuthProfileRequest, - SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, - UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, - UsageTimelinePoint, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, - YamlImportJobId, YamlImportJobStatus, + AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, + CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, + CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, + DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, + PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, + UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, + WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, + YamlImportJobStatus, }, }; @@ -68,6 +69,251 @@ impl PostgresRegistry { rows.iter().map(map_workspace_record).collect() } + pub async fn list_workspaces_for_user( + &self, + user_id: &UserId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + w.id, + w.slug, + w.display_name, + w.status, + w.settings_json, + to_char(w.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(w.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + m.role + from memberships m + join workspaces w on w.id = m.workspace_id + where m.user_id = $1 + order by w.slug asc", + ) + .bind(user_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_workspace_membership_record).collect() + } + + pub async fn upsert_bootstrap_user( + &self, + email: &str, + display_name: &str, + password_hash: &str, + ) -> Result { + let row = sqlx::query( + "insert into users ( + id, + email, + display_name, + password_hash, + status, + created_at + ) values ( + $1, $2, $3, $4, 'active', now() + ) + on conflict (email) do update + set display_name = excluded.display_name, + password_hash = excluded.password_hash, + status = 'active' + returning id", + ) + .bind(format!("user_{}", uuid::Uuid::now_v7().simple())) + .bind(email) + .bind(display_name) + .bind(password_hash) + .fetch_one(&self.pool) + .await?; + + Ok(UserId::new(row.try_get::("id")?)) + } + + pub async fn ensure_membership( + &self, + workspace_id: &WorkspaceId, + user_id: &UserId, + role: MembershipRole, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into memberships ( + workspace_id, + user_id, + role, + created_at + ) values ( + $1, $2, $3, now() + ) + on conflict (workspace_id, user_id) do update + set role = excluded.role", + ) + .bind(workspace_id.as_str()) + .bind(user_id.as_str()) + .bind(serialize_enum_text(&role, "role")?) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn get_auth_user_by_email( + &self, + email: &str, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + email, + display_name, + password_hash, + status, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at + from users + where email = $1 + limit 1", + ) + .bind(email) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_auth_user_record).transpose() + } + + pub async fn create_user_session( + &self, + session_id: &UserSessionId, + user_id: &UserId, + secret_hash: &str, + expires_at: &str, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into user_sessions ( + id, + user_id, + secret_hash, + status, + expires_at, + last_seen_at, + created_at + ) values ( + $1, + $2, + $3, + 'active', + $4::timestamptz, + now(), + now() + )", + ) + .bind(session_id.as_str()) + .bind(user_id.as_str()) + .bind(secret_hash) + .bind(expires_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn get_user_session( + &self, + session_id: &UserSessionId, + secret_hash: &str, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + s.id, + s.user_id, + u.email, + u.display_name, + u.status, + to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at + from user_sessions s + join users u on u.id = s.user_id + where s.id = $1 + and s.secret_hash = $2 + and s.status = 'active' + and s.expires_at > now() + limit 1", + ) + .bind(session_id.as_str()) + .bind(secret_hash) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let user_id = UserId::new(row.try_get::("user_id")?); + let user = User { + id: user_id.clone(), + email: row.try_get("email")?, + display_name: row.try_get("display_name")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + created_at: row.try_get("created_at")?, + }; + let memberships = self.list_workspaces_for_user(&user_id).await?; + + Ok(Some(SessionRecord { + session_id: UserSessionId::new(row.try_get::("id")?), + user, + memberships, + })) + } + + pub async fn touch_user_session( + &self, + session_id: &UserSessionId, + ) -> Result<(), RegistryError> { + sqlx::query( + "update user_sessions + set last_seen_at = now() + where id = $1", + ) + .bind(session_id.as_str()) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn revoke_user_session( + &self, + session_id: &UserSessionId, + ) -> Result<(), RegistryError> { + sqlx::query( + "update user_sessions + set status = 'revoked' + where id = $1", + ) + .bind(session_id.as_str()) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn user_has_workspace_access( + &self, + user_id: &UserId, + workspace_id: &WorkspaceId, + ) -> Result { + let row = sqlx::query( + "select exists( + select 1 + from memberships + where user_id = $1 + and workspace_id = $2 + ) as allowed", + ) + .bind(user_id.as_str()) + .bind(workspace_id.as_str()) + .fetch_one(&self.pool) + .await?; + + Ok(row.try_get("allowed")?) + } + pub async fn list_memberships( &self, workspace_id: &WorkspaceId, @@ -1204,7 +1450,9 @@ impl PostgresRegistry { schema: Option<&str>, ) -> Result { let pool = PgPoolOptions::new() + .min_connections(0) .max_connections(1) + .idle_timeout(std::time::Duration::from_secs(1)) .connect(database_url) .await?; @@ -2404,6 +2652,23 @@ fn map_workspace_record(row: &PgRow) -> Result { }) } +fn map_workspace_membership_record( + row: &PgRow, +) -> Result { + Ok(WorkspaceMembershipRecord { + workspace: Workspace { + id: WorkspaceId::new(row.try_get::("id")?), + slug: row.try_get("slug")?, + display_name: row.try_get("display_name")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + settings: row.try_get::, _>("settings_json")?.0, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }, + role: deserialize_enum_text(&row.try_get::("role")?, "role")?, + }) +} + fn map_membership_record(row: &PgRow) -> Result { Ok(MembershipRecord { workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), @@ -2419,6 +2684,19 @@ fn map_membership_record(row: &PgRow) -> Result }) } +fn map_auth_user_record(row: &PgRow) -> Result { + Ok(AuthUserRecord { + user: User { + id: UserId::new(row.try_get::("id")?), + email: row.try_get("email")?, + display_name: row.try_get("display_name")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + created_at: row.try_get("created_at")?, + }, + password_hash: row.try_get("password_hash")?, + }) +} + fn map_invitation_record(row: &PgRow) -> Result { Ok(InvitationRecord { invitation: InvitationToken { diff --git a/docs/admin-api.md b/docs/admin-api.md index 27c6284..fcdd41c 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -22,6 +22,7 @@ ## 3. Основные ресурсы - `workspaces` +- `auth` - `memberships` - `invitations` - `operations` @@ -60,7 +61,20 @@ - `POST /invitations` возвращает metadata invitation и одноразовый `invite_token`; - `invite_token` доступен только в create-response и не возвращается повторно в list endpoints. -### 5.2. Operations +### 5.2. Auth and session + +- `POST /api/auth/login` +- `POST /api/auth/logout` +- `GET /api/auth/session` + +Контракт: + +- `POST /login` принимает `email` и `password`; +- при успешном логине backend выставляет `HttpOnly` session cookie; +- `GET /session` возвращает текущего пользователя и memberships; +- `POST /logout` инвалидирует текущую session. + +### 5.3. Operations - `GET /api/admin/workspaces/{workspace_id}/operations` - `POST /api/admin/workspaces/{workspace_id}/operations` @@ -75,7 +89,7 @@ - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/export` - `POST /api/admin/workspaces/{workspace_id}/operations/import` -### 5.3. Samples and descriptors +### 5.4. Samples and descriptors - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json` @@ -84,7 +98,7 @@ - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/descriptor-set` - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/grpc/services` -### 5.4. Upstream auth profiles +### 5.5. Upstream auth profiles - `GET /api/admin/workspaces/{workspace_id}/auth-profiles` - `POST /api/admin/workspaces/{workspace_id}/auth-profiles` @@ -92,7 +106,7 @@ - `PATCH /api/admin/workspaces/{workspace_id}/auth-profiles/{auth_profile_id}` - `DELETE /api/admin/workspaces/{workspace_id}/auth-profiles/{auth_profile_id}` -### 5.5. Agents +### 5.6. Agents - `GET /api/admin/workspaces/{workspace_id}/agents` - `POST /api/admin/workspaces/{workspace_id}/agents` @@ -105,7 +119,7 @@ - `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings` - `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}` -### 5.6. Platform API keys +### 5.7. Platform API keys - `GET /api/admin/workspaces/{workspace_id}/platform-api-keys` - `POST /api/admin/workspaces/{workspace_id}/platform-api-keys` @@ -118,7 +132,7 @@ - `secret` доступен только в create-response; - list endpoints возвращают только metadata, `prefix`, `status`, `scopes` и `last_used_at`. -### 5.7. Observability +### 5.8. Observability - `GET /api/admin/workspaces/{workspace_id}/logs` - `GET /api/admin/workspaces/{workspace_id}/logs/{log_id}` diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 514dde5..3e91af1 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -378,35 +378,33 @@ UI-файлы: Что уже есть реально: -- внешний `Basic Auth` на `nginx` +- login screen и mock redirect flow -Что отсутствует: +Что уже есть: - session auth backend; -- login endpoint; -- logout endpoint; -- current user endpoint - -Отдельный конфликт: - -- это не баг реализации, а отсутствие целого auth слоя; -- login page пока не должна позиционироваться как production-ready flow. +- `POST /api/auth/login`; +- `POST /api/auth/logout`; +- `GET /api/auth/session`; +- protected page guard через backend session. Простой итог: -- пока не интегрировать как реальный backend flow; -- оставить как временный mock screen до отдельного решения по auth. +- login уже перешел на live backend flow; +- защищенные страницы проверяют server-side session; +- `localStorage` больше не должен быть source of truth для auth. ## 5. Отдельные UI-vs-backend конфликты ### 5.1. Login vs Basic Auth -UI предполагает встроенный login, backend сейчас защищен внешним `Basic Auth`. +Конфликт закрыт. UI использует встроенный login, backend перешел на app-level session auth. Решение: -- не делать вид, что login уже рабочий; -- отложить интеграцию login до отдельного auth этапа. +- использовать `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/session`; +- хранить auth state в `HttpOnly` cookie; +- использовать `localStorage` только как UI mirror для display state и не считать его source of truth. ### 5.2. Agent draft/edit lifecycle diff --git a/docs/architecture.md b/docs/architecture.md index c74ce3e..3bcca6f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,7 @@ Crank - платформа для публикации внешних API в в Отдельный слой, не связанный с upstream auth: - `User` +- `UserSession` - `Membership` - `Invitation` - `PlatformApiKey` @@ -215,6 +216,7 @@ GraphQL в MCP публикуется как фиксированная опер - `AgentOperationBinding` - `AuthProfile` - `PlatformApiKey` +- `UserSession` - `InvocationLog` - `UsageRollup` diff --git a/docs/as-is-to-be.md b/docs/as-is-to-be.md index ada15dd..653515d 100644 --- a/docs/as-is-to-be.md +++ b/docs/as-is-to-be.md @@ -194,12 +194,15 @@ Что уже есть: -- внешний `Basic Auth` на уровне `nginx`. +- mock `login.html` и `login.js`; +- `User`, `Membership`, `Invitation` и `PlatformApiKey` как часть access layer. Чего не хватает: -- либо собственный auth/session backend; -- либо временный согласованный bridge, если login screen оставляем как demo flow. +- app-level auth/session backend; +- password hash storage; +- session cookie; +- current user endpoint. ## 5. Архитектурные конфликты, которые нужно разобрать отдельно @@ -232,7 +235,13 @@ Решение: -- зафиксировать временную и целевую auth model отдельно. +- убрать внешний `Basic Auth` как основной способ входа; +- реализовать app-level auth/session backend; +- отделить browser session auth от `PlatformApiKey`. + +Статус: + +- закрыто в `feat/auth-foundation`. ## 6. Приоритет реализации diff --git a/docs/data-model.md b/docs/data-model.md index 14c134e..251ded5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -182,10 +182,35 @@ - `id` - `email` - `display_name` +- `password_hash` - `status` - `created_at` -### 3.9. `Membership` +Пароль: + +- хранится только как `Argon2id` hash; +- plaintext пароль не сохраняется; +- верификация использует `password_pepper` из env. + +### 3.9. `UserSession` + +Поля: + +- `id` +- `user_id` +- `secret_hash` +- `status` +- `expires_at` +- `last_seen_at` +- `created_at` + +Секрет: + +- браузеру выдается только opaque session token; +- в persistent storage сохраняется только `secret_hash`; +- подпись и верификация используют `session_secret` из env. + +### 3.10. `Membership` Поля: @@ -194,7 +219,7 @@ - `role` - `created_at` -### 3.10. `InvitationToken` +### 3.11. `InvitationToken` Поля: @@ -211,7 +236,7 @@ - полный invite token показывается только один раз при создании; - в persistent storage сохраняется только `token_hash`. -### 3.11. `InvocationLog` +### 3.12. `InvocationLog` Продуктовая запись о вызове tool. @@ -230,7 +255,7 @@ - `response_preview` - `created_at` -### 3.12. `UsageRollup` +### 3.13. `UsageRollup` Агрегированная статистика по периоду. diff --git a/docs/database-schema.md b/docs/database-schema.md index 20d5e51..7fe0b05 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -33,6 +33,7 @@ - `workspaces` - `users` +- `user_sessions` - `memberships` - `invitation_tokens` - `operations` @@ -166,9 +167,24 @@ - `id` - `email` - `display_name` +- `password_hash` - `status` - `created_at` +### `user_sessions` + +- `id` +- `user_id` +- `secret_hash` +- `status` +- `expires_at` +- `last_seen_at` +- `created_at` + +Ограничения: + +- `unique (user_id, id)` + ### `memberships` - `workspace_id` diff --git a/docs/deployment.md b/docs/deployment.md index c5504db..b963c27 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -221,5 +221,5 @@ CD для MVP должен: - TLS и renewal сертификатов; - backup для `postgres` и artifact storage; - секреты вне репозитория; -- ограничение доступа к `admin-api`; +- app-level auth для `admin-api` и bootstrap admin credentials в env; - rollback на предыдущий image tag или предыдущую compose revision. diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 3695e6e..0bc39f3 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -107,16 +107,26 @@ Local development: - локальное окружение должно иметь доступ к `PostgreSQL`; - локальный storage; -- упрощенная auth-модель admin-api. +- app-level auth и bootstrap admin user через `.env`. Demo/deployment: - `PostgreSQL`; - локальный или сетевой storage; -- включенная auth-защита admin-api; +- включенная app-level auth-защита admin-api; - стабильный `Streamable HTTP` endpoint для MCP. - containerized runtime через `Docker` и `docker-compose`. +### Auth env + +Для app-level auth нужны: + +- `CRANK_SESSION_SECRET` +- `CRANK_PASSWORD_PEPPER` +- `CRANK_BOOTSTRAP_ADMIN_EMAIL` +- `CRANK_BOOTSTRAP_ADMIN_PASSWORD` +- `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME` + ## 8.1. Delivery artifacts Для production-like запуска проект должен поставляться с: