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
+6
View File
@@ -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
Generated
+180
View File
@@ -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"
+4 -1
View File
@@ -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"
+9 -6
View File
@@ -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
+4
View File
@@ -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
+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(),
+193
View File
@@ -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<WorkspaceMembershipRecord>,
}
#[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<String, ApiError> {
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<SessionCookie, ApiError> {
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<AppState>,
jar: CookieJar,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
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<AppState>,
jar: CookieJar,
original_uri: OriginalUri,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
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<AuthenticatedSession, ApiError> {
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<WorkspaceId> {
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))
}
+23 -1
View File
@@ -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<String>) -> Self {
Self::Unauthorized {
message: message.into(),
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden {
message: message.into(),
}
}
pub fn validation(message: impl Into<String>) -> 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)
+26 -3
View File
@@ -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<dyn std::error::Error>> {
@@ -26,11 +32,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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::<i64>().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?;
+1
View File
@@ -1,5 +1,6 @@
pub mod access;
pub mod agents;
pub mod auth;
pub mod auth_profiles;
pub mod observability;
pub mod operations;
+52
View File
@@ -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<AppState>,
jar: CookieJar,
Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
jar: CookieJar,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
jar: CookieJar,
) -> Result<Json<serde_json::Value>, 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)))
}
+15 -4
View File
@@ -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<AppState>) -> Result<Json<Value>, ApiError> {
let items = state.service.list_workspaces().await?;
pub async fn list_workspaces(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<Value>, 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<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<WorkspacePayload>,
) -> Result<Json<Value>, ApiError> {
let workspace = state.service.create_workspace(payload).await?;
let workspace = state
.service
.create_workspace(&session.user.id, payload)
.await?;
Ok(Json(json!(workspace)))
}
+176 -8
View File
@@ -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<WorkspaceMembershipRecord>,
}
#[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<Vec<WorkspaceRecord>, 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<Option<AuthenticatedSession>, 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<Vec<WorkspaceMembershipRecord>, 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<bool, ApiError> {
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<WorkspaceRecord, ApiError> {
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<Option<SessionResponse>, 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,
+3 -2
View File
@@ -11,11 +11,12 @@
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/catalog.css">
<link rel="stylesheet" href="css/wizard.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body x-data="agents">
@@ -75,7 +76,7 @@
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" onclick="localStorage.removeItem('crank_user'); window.location.href='login.html'">
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
+3 -2
View File
@@ -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); }
</style>
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
<script src="js/workspace.js"></script>
</head>
<body>
+2
View File
@@ -10,6 +10,8 @@
<link rel="stylesheet" href="css/login.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/auth.js"></script>
</head>
<body>
+2 -1
View File
@@ -10,11 +10,12 @@
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/logs.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body>
+2 -1
View File
@@ -10,11 +10,12 @@
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/settings.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body>
+2 -1
View File
@@ -10,11 +10,12 @@
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/usage.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body>
+2 -1
View File
@@ -9,11 +9,12 @@
<link rel="stylesheet" href="../../css/layout.css">
<link rel="stylesheet" href="../../css/wizard.css">
<link rel="stylesheet" href="../../css/pages.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('../login.html');}catch(e){}</script>
<script src="../../js/config.js"></script>
<script src="../../js/i18n.js"></script>
<script src="../../js/workspace.js"></script>
<script src="../../js/api.js"></script>
<script src="../../js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
</head>
<body>
+2 -1
View File
@@ -11,10 +11,11 @@
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/wizard.css">
<link rel="stylesheet" href="css/workspace-setup.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body>
<div class="ws-setup-page">
+2 -1
View File
@@ -9,11 +9,12 @@
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/catalog.css">
<link rel="stylesheet" href="css/pages.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('html/login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body x-data="catalog()">
+28 -6
View File
@@ -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 @@
);
},
};
}());
+143
View File
@@ -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,
};
}());
+1 -2
View File
@@ -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();
},
};
});
+40 -32
View File
@@ -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.');
}
});
});
}());
+60 -56
View File
@@ -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();
}
})();
+1
View File
@@ -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);
+1 -1
View File
@@ -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,
+1
View File
@@ -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
+5 -4
View File
@@ -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;
+19
View File
@@ -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,
+20 -1
View File
@@ -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<WorkspaceMembershipRecord>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InvitationRecord {
pub invitation: InvitationToken,
+292 -14
View File
@@ -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<Vec<WorkspaceMembershipRecord>, 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<UserId, RegistryError> {
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::<String, _>("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<Option<AuthUserRecord>, 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<Option<SessionRecord>, 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::<String, _>("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::<String, _>("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::<String, _>("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<bool, RegistryError> {
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<Self, RegistryError> {
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<WorkspaceRecord, RegistryError> {
})
}
fn map_workspace_membership_record(
row: &PgRow,
) -> Result<WorkspaceMembershipRecord, RegistryError> {
Ok(WorkspaceMembershipRecord {
workspace: Workspace {
id: WorkspaceId::new(row.try_get::<String, _>("id")?),
slug: row.try_get("slug")?,
display_name: row.try_get("display_name")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
settings: row.try_get::<Json<Value>, _>("settings_json")?.0,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
},
role: deserialize_enum_text(&row.try_get::<String, _>("role")?, "role")?,
})
}
fn map_membership_record(row: &PgRow) -> Result<MembershipRecord, RegistryError> {
Ok(MembershipRecord {
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
@@ -2419,6 +2684,19 @@ fn map_membership_record(row: &PgRow) -> Result<MembershipRecord, RegistryError>
})
}
fn map_auth_user_record(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
Ok(AuthUserRecord {
user: User {
id: UserId::new(row.try_get::<String, _>("id")?),
email: row.try_get("email")?,
display_name: row.try_get("display_name")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
created_at: row.try_get("created_at")?,
},
password_hash: row.try_get("password_hash")?,
})
}
fn map_invitation_record(row: &PgRow) -> Result<InvitationRecord, RegistryError> {
Ok(InvitationRecord {
invitation: InvitationToken {
+20 -6
View File
@@ -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}`
+13 -15
View File
@@ -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
+2
View File
@@ -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`
+13 -4
View File
@@ -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. Приоритет реализации
+29 -4
View File
@@ -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`
Агрегированная статистика по периоду.
+16
View File
@@ -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`
+1 -1
View File
@@ -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.
+12 -2
View File
@@ -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 запуска проект должен поставляться с: