feat: add app-level authentication foundation
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod access;
|
||||
pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
}());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}());
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
Агрегированная статистика по периоду.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 запуска проект должен поставляться с:
|
||||
|
||||
Reference in New Issue
Block a user