From a1056bb237f1e77138fb96d5fcaa9665611b88b4 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Sun, 30 Nov 2025 18:06:50 +0100 Subject: [PATCH] feat: add admin GraphQL API, background jobs, and user sync CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Features: - Admin GraphQL API with dual endpoints (Seaography + custom) - Background job scheduler with execution tracking - Idempotent user sync CLI for Kubernetes deployments - Secure PUT /properties endpoint with Bearer token auth Admin GraphQL API: - Entity CRUD via Seaography at /admin/graphql - Custom job management API at /admin/jobs - Mutations: triggerJob - Queries: jobLogs, availableJobs - GraphiQL playgrounds for both endpoints Background Jobs: - tokio-cron-scheduler integration - Automated cleanup of expired sessions (hourly) - Automated cleanup of expired refresh tokens (hourly) - Job execution tracking in database - Manual job triggering via GraphQL User Sync CLI: - Command: barycenter sync-users --file users.json - Idempotent user synchronization from JSON - Creates new users with hashed passwords - Updates existing users (enabled, email_verified, email) - Syncs custom properties per user - Perfect for Kubernetes init containers Security Enhancements: - PUT /properties endpoint requires Bearer token - Users can only modify their own properties - Public registration disabled by default - Admin API on separate port for network isolation Database: - New job_executions table for job tracking - User update functions (update_user, update_user_email) - PostgreSQL + SQLite support maintained Configuration: - allow_public_registration setting (default: false) - admin_port setting (default: main port + 1) Documentation: - Comprehensive Kubernetes deployment guide - User sync JSON schema and examples - Init container and CronJob examples - Production deployment patterns Files Added: - src/admin_graphql.rs - GraphQL schema builders - src/admin_mutations.rs - Custom mutations and queries - src/jobs.rs - Job scheduler and tracking - src/user_sync.rs - User sync logic - src/entities/ - SeaORM entities (8 entities) - docs/kubernetes-deployment.md - K8s deployment guide - users.json.example - User sync example Dependencies: - tokio-cron-scheduler 0.13 - seaography 1.1.4 - async-graphql 7.0 - async-graphql-axum 7.0 πŸ€– Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 27 +- Cargo.lock | 1022 +++++++++++++++++++++++++++++++-- Cargo.toml | 12 +- docs/kubernetes-deployment.md | 931 ++++++++++++++++++++++++++++++ src/admin_graphql.rs | 113 ++++ src/admin_mutations.rs | 132 +++++ src/entities/access_token.rs | 20 + src/entities/auth_code.rs | 25 + src/entities/client.rs | 18 + src/entities/job_execution.rs | 20 + src/entities/mod.rs | 17 + src/entities/property.rs | 18 + src/entities/refresh_token.rs | 21 + src/entities/session.rs | 20 + src/entities/user.rs | 21 + src/jobs.rs | 189 ++++++ src/main.rs | 50 +- src/settings.rs | 16 +- src/storage.rs | 642 ++++++++++++--------- src/user_sync.rs | 184 ++++++ src/web.rs | 127 +++- users.json.example | 55 ++ 22 files changed, 3331 insertions(+), 349 deletions(-) create mode 100644 docs/kubernetes-deployment.md create mode 100644 src/admin_graphql.rs create mode 100644 src/admin_mutations.rs create mode 100644 src/entities/access_token.rs create mode 100644 src/entities/auth_code.rs create mode 100644 src/entities/client.rs create mode 100644 src/entities/job_execution.rs create mode 100644 src/entities/mod.rs create mode 100644 src/entities/property.rs create mode 100644 src/entities/refresh_token.rs create mode 100644 src/entities/session.rs create mode 100644 src/entities/user.rs create mode 100644 src/jobs.rs create mode 100644 src/user_sync.rs create mode 100644 users.json.example diff --git a/CLAUDE.md b/CLAUDE.md index f3c6463..9eac3e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Barycenter is an OpenID Connect Identity Provider (IdP) implementing OAuth 2.0 Authorization Code flow with PKCE. The project is written in Rust using axum for the web framework, SeaORM for database access (SQLite), and josekit for JOSE/JWT operations. +Barycenter is an OpenID Connect Identity Provider (IdP) implementing OAuth 2.0 Authorization Code flow with PKCE. The project is written in Rust using axum for the web framework, SeaORM for database access (SQLite and PostgreSQL), and josekit for JOSE/JWT operations. ## Build and Development Commands @@ -68,6 +68,27 @@ The application loads configuration from: Environment variables use double underscores as separators for nested keys. +### Database Configuration + +Barycenter supports both SQLite and PostgreSQL databases. The database backend is automatically detected from the connection string: + +**SQLite (default):** +```toml +[database] +url = "sqlite://barycenter.db?mode=rwc" +``` + +**PostgreSQL:** +```toml +[database] +url = "postgresql://user:password@localhost/barycenter" +``` + +Or via environment variable: +```bash +export BARYCENTER__DATABASE__URL="postgresql://user:password@localhost/barycenter" +``` + ## Architecture and Module Structure ### Entry Point (`src/main.rs`) @@ -81,14 +102,14 @@ The application initializes in this order: ### Settings (`src/settings.rs`) Manages configuration with four main sections: - `Server`: listen address and public base URL (issuer) -- `Database`: SQLite connection string +- `Database`: database connection string (SQLite or PostgreSQL) - `Keys`: JWKS and private key paths, signing algorithm - `Federation`: trust anchor URLs (future use) The `issuer()` method returns the OAuth issuer URL, preferring `public_base_url` or falling back to `http://{host}:{port}`. ### Storage (`src/storage.rs`) -Database layer with raw SQL using SeaORM's `DatabaseConnection`. Tables: +Database layer with raw SQL using SeaORM's `DatabaseConnection`. Supports both SQLite and PostgreSQL backends, automatically detected from the connection string. Tables: - `clients`: OAuth client registrations (client_id, client_secret, redirect_uris) - `auth_codes`: Authorization codes with PKCE challenge, subject, scope, nonce - `access_tokens`: Bearer tokens with subject, scope, expiration diff --git a/Cargo.lock b/Cargo.lock index e0c4aa5..8e5fe0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -17,6 +27,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -133,6 +154,113 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "async-graphql" +version = "7.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "036618f842229ba0b89652ffe425f96c7c16a49f7e3cb23b56fca7f61fd74980" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-stream", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "fast_chemail", + "fnv", + "futures-channel", + "futures-timer", + "futures-util", + "handlebars", + "http", + "indexmap 2.12.1", + "lru", + "mime", + "multer", + "num-traits", + "pin-project-lite", + "regex", + "rust_decimal", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "async-graphql-axum" +version = "7.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8725874ecfbf399e071150b8619c4071d7b2b7a2f117e173dddef53c6bdb6bb1" +dependencies = [ + "async-graphql", + "axum 0.8.7", + "bytes", + "futures-util", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", +] + +[[package]] +name = "async-graphql-derive" +version = "7.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd45deb3dbe5da5cdb8d6a670a7736d735ba65b455328440f236dfb113727a3d" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling 0.20.11", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum", + "syn 2.0.111", + "thiserror 1.0.69", +] + +[[package]] +name = "async-graphql-parser" +version = "7.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b7607e59424a35dadbc085b0d513aa54ec28160ee640cf79ec3b634eba66d3" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de" +dependencies = [ + "bytes", + "indexmap 2.12.1", + "serde", + "serde_json", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -152,7 +280,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -163,7 +291,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -228,6 +356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core 0.5.5", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -246,8 +375,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -324,6 +455,8 @@ version = "0.2.0-alpha.12" dependencies = [ "anyhow", "argon2", + "async-graphql", + "async-graphql-axum", "axum 0.8.7", "base64ct", "chrono", @@ -337,6 +470,7 @@ dependencies = [ "regex", "reqwest", "sea-orm", + "seaography", "serde", "serde_json", "serde_urlencoded", @@ -345,6 +479,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", + "tokio-cron-scheduler", "tower", "tower_governor", "tracing", @@ -377,6 +512,20 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bigdecimal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "bitflags" version = "2.10.0" @@ -386,6 +535,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -404,17 +565,71 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] [[package]] name = "cc" @@ -483,7 +698,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -639,6 +854,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "croner" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" +dependencies = [ + "chrono", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -706,7 +930,17 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] @@ -715,8 +949,22 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.111", ] [[package]] @@ -730,7 +978,18 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.111", ] [[package]] @@ -739,9 +998,9 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -757,6 +1016,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "der" version = "0.7.10" @@ -795,7 +1060,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "unicode-xid", ] @@ -819,7 +1084,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -945,6 +1210,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -956,6 +1232,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1051,6 +1336,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1118,7 +1409,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1251,11 +1542,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "handlebars" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -1263,7 +1571,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", "allocator-api2", ] @@ -1338,6 +1646,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.3.1" @@ -1629,7 +1946,7 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1669,6 +1986,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1734,6 +2060,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1778,6 +2115,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1805,6 +2151,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1838,7 +2194,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1874,6 +2230,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1928,6 +2301,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1950,6 +2333,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2033,7 +2427,7 @@ dependencies = [ "ed25519-dalek", "hmac", "http", - "itertools", + "itertools 0.10.5", "log", "oauth2", "p256", @@ -2075,7 +2469,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2145,7 +2539,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2269,7 +2663,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2282,6 +2676,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -2299,7 +2702,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2380,6 +2783,37 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -2397,7 +2831,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "version_check", "yansi", ] @@ -2408,6 +2842,26 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "publicsuffix" version = "2.3.0" @@ -2503,6 +2957,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2597,7 +3057,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2629,6 +3089,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -2701,6 +3170,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ron" version = "0.8.1" @@ -2743,6 +3241,22 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -2863,6 +3377,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "sea-orm" version = "1.1.19" @@ -2871,19 +3398,26 @@ checksum = "6d945f62558fac19e5988680d2fdf747b734c2dbc6ce2cb81ba33ed8dde5b103" dependencies = [ "async-stream", "async-trait", + "bigdecimal", + "chrono", "derive_more", "futures-util", "log", "ouroboros", + "pgvector", + "rust_decimal", "sea-orm-macros", "sea-query", "sea-query-binder", "serde", + "serde_json", "sqlx", "strum", "thiserror 2.0.17", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -2893,9 +3427,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c2e64a50a9cc8339f10a27577e10062c7f995488e469f2c95762c5ee847832" dependencies = [ "heck 0.5.0", + "proc-macro-crate", "proc-macro2", "quote", - "syn", + "sea-bae", + "syn 2.0.111", "unicode-ident", ] @@ -2905,8 +3441,12 @@ version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" dependencies = [ + "chrono", "inherent", "ordered-float 4.6.0", + "rust_decimal", + "serde_json", + "uuid", ] [[package]] @@ -2915,8 +3455,34 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" dependencies = [ + "chrono", + "rust_decimal", "sea-query", + "serde_json", "sqlx", + "uuid", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "seaography" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5e0455935e4f31eb64ce606d9963715efd4c1856edb129619126f6b5372fcf" +dependencies = [ + "async-graphql", + "fnv", + "heck 0.4.1", + "itertools 0.12.1", + "lazy_static", + "sea-orm", + "serde_json", + "thiserror 1.0.69", ] [[package]] @@ -2999,7 +3565,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3082,10 +3648,21 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -3139,6 +3716,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.11" @@ -3150,6 +3733,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3197,6 +3783,8 @@ checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", "sqlx-sqlite", ] @@ -3208,6 +3796,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -3223,8 +3812,10 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", + "rust_decimal", "rustls", "serde", + "serde_json", "sha2", "smallvec", "thiserror 2.0.17", @@ -3232,6 +3823,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", "webpki-roots 0.26.11", ] @@ -3245,7 +3837,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.111", ] [[package]] @@ -3265,12 +3857,99 @@ dependencies = [ "serde_json", "sha2", "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.111", "tokio", "url", ] +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + [[package]] name = "sqlx-sqlite" version = "0.8.6" @@ -3278,6 +3957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -3293,6 +3973,7 @@ dependencies = [ "thiserror 2.0.17", "tracing", "url", + "uuid", ] [[package]] @@ -3307,6 +3988,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3318,6 +4016,22 @@ name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.111", +] [[package]] name = "subtle" @@ -3346,6 +4060,17 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.111" @@ -3374,7 +4099,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3398,6 +4123,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.23.0" @@ -3457,7 +4188,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3468,7 +4199,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3562,6 +4293,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5597b569b4712cf78aa0c9ae29742461b7bda1e49c2a5fdad1d79bf022f8f0" +dependencies = [ + "chrono", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -3570,7 +4316,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3604,6 +4350,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -3612,6 +4370,7 @@ checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3625,8 +4384,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3638,6 +4397,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3647,11 +4415,32 @@ dependencies = [ "indexmap 2.12.1", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.1", + "toml_datetime 0.7.3", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" @@ -3740,7 +4529,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3788,6 +4577,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3800,6 +4606,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -3812,6 +4624,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -3860,6 +4687,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3872,6 +4705,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3914,6 +4759,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -3959,7 +4810,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -4010,6 +4861,16 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4053,7 +4914,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4064,7 +4925,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4102,6 +4963,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4129,6 +4999,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4162,6 +5047,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4174,6 +5065,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4186,6 +5083,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4210,6 +5113,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4222,6 +5131,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4234,6 +5149,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4246,6 +5167,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4279,6 +5206,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yaml-rust2" version = "0.8.1" @@ -4315,7 +5251,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -4336,7 +5272,7 @@ checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4356,7 +5292,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -4396,5 +5332,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] diff --git a/Cargo.toml b/Cargo.toml index 73dc50e..afdb128 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_with = "3" -# SeaORM for SQLite -sea-orm = { version = "1", default-features = false, features = ["sqlx-sqlite", "runtime-tokio-rustls"] } +# SeaORM for SQLite and PostgreSQL +sea-orm = { version = "1", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"] } # JOSE / JWKS & JWT josekit = "0.10" @@ -48,6 +48,14 @@ tower_governor = "0.4" regex = "1" url = "2" +# GraphQL Admin API +seaography = { version = "1", features = ["with-decimal", "with-chrono", "with-uuid"] } +async-graphql = "7" +async-graphql-axum = "7" + +# Background job scheduler +tokio-cron-scheduler = "0.13" + [dev-dependencies] openidconnect = { version = "4", features = ["reqwest-blocking"] } oauth2 = "5" diff --git a/docs/kubernetes-deployment.md b/docs/kubernetes-deployment.md new file mode 100644 index 0000000..310aa76 --- /dev/null +++ b/docs/kubernetes-deployment.md @@ -0,0 +1,931 @@ +# Kubernetes Deployment Guide + +This guide covers deploying Barycenter in Kubernetes with user sync, persistent storage, and proper configuration management. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Architecture Overview](#architecture-overview) +- [Prerequisites](#prerequisites) +- [Configuration](#configuration) +- [Storage](#storage) +- [User Management](#user-management) +- [Deployment](#deployment) +- [Services](#services) +- [Complete Example](#complete-example) +- [Production Considerations](#production-considerations) + +## Quick Start + +```bash +# Create namespace +kubectl create namespace barycenter + +# Apply all manifests +kubectl apply -f deploy/kubernetes/ +``` + +## Architecture Overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Kubernetes Cluster β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Barycenter Deployment β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Init Container │───▢│ Main Container β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ (User Sync) β”‚ β”‚ (OIDC Server) β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–Ό β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ PersistentVolume β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ (SQLite/Data) β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ ConfigMap β”‚ β”‚ Secret β”‚ β”‚ +β”‚ β”‚ (config.toml) β”‚ β”‚ (users.json) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Services β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Public β”‚ β”‚ Admin β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ (Port 8080) β”‚ β”‚ (Port 8081) β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Prerequisites + +- Kubernetes cluster (1.20+) +- `kubectl` configured +- Container registry access (or Docker Hub) +- Persistent storage provisioner (optional, for production) + +## Configuration + +### ConfigMap for Application Configuration + +Create `barycenter-config.yaml`: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: barycenter-config + namespace: barycenter +data: + config.toml: | + [server] + host = "0.0.0.0" + port = 8080 + admin_port = 8081 + # IMPORTANT: Set this to your actual domain in production + public_base_url = "https://auth.example.com" + allow_public_registration = false + + [database] + # SQLite for simplicity (use PostgreSQL in production) + url = "sqlite:///data/barycenter.db?mode=rwc" + + [keys] + jwks_path = "/data/jwks.json" + private_key_path = "/data/private_key.pem" + alg = "RS256" + + [federation] + trust_anchors = [] +``` + +### Secret for User Data + +Create `barycenter-users-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: barycenter-users + namespace: barycenter +type: Opaque +stringData: + users.json: | + { + "users": [ + { + "username": "admin", + "email": "admin@example.com", + "password": "CHANGE-ME-IN-PRODUCTION", + "enabled": true, + "email_verified": true, + "properties": { + "role": "administrator", + "display_name": "System Administrator" + } + }, + { + "username": "app-service", + "email": "service@example.com", + "password": "service-account-password", + "enabled": true, + "email_verified": true, + "properties": { + "role": "service_account", + "display_name": "Application Service Account" + } + } + ] + } +``` + +**IMPORTANT:** In production, generate this secret from a secure source: + +```bash +# Generate from template with environment variables +cat users.json.template | envsubst | kubectl create secret generic barycenter-users \ + --namespace=barycenter \ + --from-file=users.json=/dev/stdin \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +## Storage + +### Development: EmptyDir (Data lost on pod restart) + +```yaml +volumes: +- name: data + emptyDir: {} +``` + +### Production: PersistentVolumeClaim + +Create `barycenter-pvc.yaml`: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: barycenter-data + namespace: barycenter +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + # Optional: Use a specific storage class + # storageClassName: fast-ssd +``` + +## User Management + +### Init Container for User Sync + +The init container runs before the main application starts and syncs users from the secret: + +```yaml +initContainers: +- name: user-sync + image: your-registry/barycenter:latest + command: + - barycenter + - sync-users + - --file + - /secrets/users.json + volumeMounts: + - name: users-secret + mountPath: /secrets + readOnly: true + - name: data + mountPath: /data + - name: config + mountPath: /app + readOnly: true + env: + - name: RUST_LOG + value: info +``` + +### Standalone User Sync Job + +For updating users without redeploying: + +Create `barycenter-user-sync-job.yaml`: + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: barycenter-user-sync + namespace: barycenter +spec: + backoffLimit: 3 + template: + metadata: + labels: + app: barycenter + component: user-sync + spec: + restartPolicy: OnFailure + containers: + - name: user-sync + image: your-registry/barycenter:latest + command: + - barycenter + - sync-users + - --file + - /secrets/users.json + volumeMounts: + - name: users-secret + mountPath: /secrets + readOnly: true + - name: data + mountPath: /data + - name: config + mountPath: /app + readOnly: true + env: + - name: RUST_LOG + value: info + volumes: + - name: users-secret + secret: + secretName: barycenter-users + - name: data + persistentVolumeClaim: + claimName: barycenter-data + - name: config + configMap: + name: barycenter-config +``` + +Run with: +```bash +kubectl apply -f barycenter-user-sync-job.yaml + +# Watch progress +kubectl logs -f job/barycenter-user-sync -n barycenter + +# Clean up job after completion +kubectl delete job barycenter-user-sync -n barycenter +``` + +### CronJob for Periodic User Sync + +Create `barycenter-user-sync-cronjob.yaml`: + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: barycenter-user-sync + namespace: barycenter +spec: + # Run every hour + schedule: "0 * * * *" + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 2 + template: + metadata: + labels: + app: barycenter + component: user-sync + spec: + restartPolicy: OnFailure + containers: + - name: user-sync + image: your-registry/barycenter:latest + command: + - barycenter + - sync-users + - --file + - /secrets/users.json + volumeMounts: + - name: users-secret + mountPath: /secrets + readOnly: true + - name: data + mountPath: /data + - name: config + mountPath: /app + readOnly: true + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + volumes: + - name: users-secret + secret: + secretName: barycenter-users + - name: data + persistentVolumeClaim: + claimName: barycenter-data + - name: config + configMap: + name: barycenter-config +``` + +## Deployment + +### Main Deployment + +Create `barycenter-deployment.yaml`: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: barycenter + namespace: barycenter + labels: + app: barycenter +spec: + replicas: 1 # NOTE: SQLite supports only 1 replica. Use PostgreSQL for HA. + selector: + matchLabels: + app: barycenter + template: + metadata: + labels: + app: barycenter + spec: + # Init container syncs users before app starts + initContainers: + - name: user-sync + image: your-registry/barycenter:latest + command: + - barycenter + - sync-users + - --file + - /secrets/users.json + volumeMounts: + - name: users-secret + mountPath: /secrets + readOnly: true + - name: data + mountPath: /data + - name: config + mountPath: /app + readOnly: true + env: + - name: RUST_LOG + value: info + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + + # Main application container + containers: + - name: barycenter + image: your-registry/barycenter:latest + ports: + - name: public + containerPort: 8080 + protocol: TCP + - name: admin + containerPort: 8081 + protocol: TCP + volumeMounts: + - name: data + mountPath: /data + - name: config + mountPath: /app + readOnly: true + env: + - name: RUST_LOG + value: info + # Liveness probe - checks if app is alive + livenessProbe: + httpGet: + path: /.well-known/openid-configuration + port: public + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + # Readiness probe - checks if app is ready to serve traffic + readinessProbe: + httpGet: + path: /.well-known/openid-configuration + port: public + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + memory: "256Mi" + cpu: "200m" + limits: + memory: "512Mi" + cpu: "500m" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + + volumes: + - name: users-secret + secret: + secretName: barycenter-users + - name: data + persistentVolumeClaim: + claimName: barycenter-data + - name: config + configMap: + name: barycenter-config + + # Security context for the pod + securityContext: + fsGroup: 1000 +``` + +## Services + +### Public Service (OIDC Endpoints) + +Create `barycenter-service-public.yaml`: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: barycenter-public + namespace: barycenter + labels: + app: barycenter + component: public +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: public + protocol: TCP + name: http + selector: + app: barycenter +``` + +### Admin Service (GraphQL API) + +Create `barycenter-service-admin.yaml`: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: barycenter-admin + namespace: barycenter + labels: + app: barycenter + component: admin +spec: + type: ClusterIP + ports: + - port: 8081 + targetPort: admin + protocol: TCP + name: http + selector: + app: barycenter +``` + +### Ingress (Optional) + +Create `barycenter-ingress.yaml`: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: barycenter + namespace: barycenter + annotations: + # cert-manager for TLS + cert-manager.io/cluster-issuer: letsencrypt-prod + # nginx ingress specific + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" +spec: + ingressClassName: nginx + tls: + - hosts: + - auth.example.com + secretName: barycenter-tls + rules: + # Public OIDC endpoints + - host: auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: barycenter-public + port: + number: 8080 +--- +# Separate ingress for admin (restrict access) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: barycenter-admin + namespace: barycenter + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + # Restrict to internal IPs only + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +spec: + ingressClassName: nginx + tls: + - hosts: + - admin.auth.example.com + secretName: barycenter-admin-tls + rules: + - host: admin.auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: barycenter-admin + port: + number: 8081 +``` + +## Complete Example + +Create a directory structure: + +``` +deploy/kubernetes/ +β”œβ”€β”€ namespace.yaml +β”œβ”€β”€ configmap.yaml +β”œβ”€β”€ secret.yaml +β”œβ”€β”€ pvc.yaml +β”œβ”€β”€ deployment.yaml +β”œβ”€β”€ service-public.yaml +β”œβ”€β”€ service-admin.yaml +└── ingress.yaml +``` + +### namespace.yaml + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: barycenter + labels: + name: barycenter +``` + +### Apply All Resources + +```bash +# Create namespace first +kubectl apply -f deploy/kubernetes/namespace.yaml + +# Apply configuration and storage +kubectl apply -f deploy/kubernetes/configmap.yaml +kubectl apply -f deploy/kubernetes/secret.yaml +kubectl apply -f deploy/kubernetes/pvc.yaml + +# Deploy application +kubectl apply -f deploy/kubernetes/deployment.yaml + +# Expose services +kubectl apply -f deploy/kubernetes/service-public.yaml +kubectl apply -f deploy/kubernetes/service-admin.yaml + +# Optional: Create ingress +kubectl apply -f deploy/kubernetes/ingress.yaml +``` + +### Verify Deployment + +```bash +# Check all resources +kubectl get all -n barycenter + +# Check init container logs (user sync) +kubectl logs -n barycenter deployment/barycenter -c user-sync + +# Check main container logs +kubectl logs -n barycenter deployment/barycenter -c barycenter -f + +# Check services +kubectl get svc -n barycenter + +# Port forward for testing +kubectl port-forward -n barycenter svc/barycenter-public 8080:8080 +kubectl port-forward -n barycenter svc/barycenter-admin 8081:8081 + +# Test OIDC discovery +curl http://localhost:8080/.well-known/openid-configuration + +# Test admin GraphQL +curl http://localhost:8081/admin/playground +``` + +## Production Considerations + +### High Availability + +**SQLite Limitation:** +- SQLite only supports single writer +- For HA, use PostgreSQL instead + +**PostgreSQL Setup:** + +1. Update `configmap.yaml`: +```yaml +[database] +url = "postgresql://barycenter:password@postgres-service:5432/barycenter" +``` + +2. Deploy PostgreSQL (or use cloud provider): +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: barycenter +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: barycenter + - name: POSTGRES_USER + value: barycenter + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: password + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi +``` + +3. Scale deployment: +```yaml +spec: + replicas: 3 # Now safe with PostgreSQL +``` + +### Security Hardening + +1. **Network Policies:** + +```yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: barycenter-network-policy + namespace: barycenter +spec: + podSelector: + matchLabels: + app: barycenter + policyTypes: + - Ingress + - Egress + ingress: + # Allow from ingress controller + - from: + - namespaceSelector: + matchLabels: + name: ingress-nginx + ports: + - protocol: TCP + port: 8080 + # Admin access only from internal + - from: + - podSelector: + matchLabels: + role: admin + ports: + - protocol: TCP + port: 8081 + egress: + # Allow DNS + - to: + - namespaceSelector: + matchLabels: + name: kube-system + ports: + - protocol: UDP + port: 53 + # Allow PostgreSQL + - to: + - podSelector: + matchLabels: + app: postgres + ports: + - protocol: TCP + port: 5432 +``` + +2. **Pod Security Standards:** + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: barycenter + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted +``` + +3. **Resource Quotas:** + +```yaml +apiVersion: v1 +kind: ResourceQuota +metadata: + name: barycenter-quota + namespace: barycenter +spec: + hard: + requests.cpu: "2" + requests.memory: 4Gi + limits.cpu: "4" + limits.memory: 8Gi + persistentvolumeclaims: "5" +``` + +### Monitoring + +1. **ServiceMonitor (Prometheus Operator):** + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: barycenter + namespace: barycenter +spec: + selector: + matchLabels: + app: barycenter + endpoints: + - port: http + interval: 30s + path: /metrics # Add metrics endpoint to Barycenter +``` + +2. **Logging:** + +```yaml +# Add to deployment +env: +- name: RUST_LOG + value: "info,barycenter=debug" +``` + +### Backup + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: barycenter-backup + namespace: barycenter +spec: + schedule: "0 2 * * *" # Daily at 2 AM + jobTemplate: + spec: + template: + spec: + containers: + - name: backup + image: alpine:latest + command: + - sh + - -c + - | + apk add --no-cache sqlite + sqlite3 /data/barycenter.db ".backup /backup/barycenter-$(date +%Y%m%d-%H%M%S).db" + # Upload to S3/GCS/etc + volumeMounts: + - name: data + mountPath: /data + readOnly: true + - name: backup + mountPath: /backup + volumes: + - name: data + persistentVolumeClaim: + claimName: barycenter-data + - name: backup + persistentVolumeClaim: + claimName: barycenter-backup + restartPolicy: OnFailure +``` + +## Troubleshooting + +### Check Init Container + +```bash +# View init container logs +kubectl logs -n barycenter deployment/barycenter -c user-sync + +# Common issues: +# - Secret not found: Check kubectl get secret -n barycenter +# - Permission denied: Check fsGroup and securityContext +# - Database locked: Check if multiple pods are running with SQLite +``` + +### Check Main Container + +```bash +# View logs +kubectl logs -n barycenter deployment/barycenter -c barycenter -f + +# Exec into pod +kubectl exec -it -n barycenter deployment/barycenter -- sh + +# Check database +ls -la /data/ +``` + +### Update Users + +```bash +# Method 1: Update secret and restart +kubectl delete secret barycenter-users -n barycenter +kubectl create secret generic barycenter-users \ + --from-file=users.json=./users.json \ + -n barycenter + +kubectl rollout restart deployment/barycenter -n barycenter + +# Method 2: Run sync job +kubectl apply -f barycenter-user-sync-job.yaml +kubectl logs -f job/barycenter-user-sync -n barycenter +``` + +## Summary + +You now have: +- βœ… Complete Kubernetes deployment setup +- βœ… User sync via init containers +- βœ… Persistent storage configuration +- βœ… Service exposure (public + admin) +- βœ… Production-ready configurations +- βœ… HA setup with PostgreSQL +- βœ… Security hardening options +- βœ… Monitoring and backup strategies + +For the actual Helm chart deployment, see `deploy/helm/barycenter/`. diff --git a/src/admin_graphql.rs b/src/admin_graphql.rs new file mode 100644 index 0000000..3fbdcb7 --- /dev/null +++ b/src/admin_graphql.rs @@ -0,0 +1,113 @@ +use async_graphql::dynamic::Schema as DynamicSchema; +use async_graphql::EmptySubscription; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; +use axum::{ + extract::State, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use sea_orm::DatabaseConnection; +use std::sync::Arc; + +use crate::admin_mutations::{AdminMutation, AdminQuery}; +use crate::entities; + +/// Initialize the Seaography admin GraphQL schema with all entities +pub fn build_seaography_schema(db: DatabaseConnection) -> DynamicSchema { + use seaography::{Builder, BuilderContext}; + + // Create a static BuilderContext for Seaography + let context: &'static BuilderContext = Box::leak(Box::new(BuilderContext::default())); + + let mut builder = Builder::new(context, db.clone()); + + // Register all entities + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + builder.register_entity::(vec![]); + + // Build and return the schema + builder.schema_builder().finish().unwrap() +} + +/// Build custom job management GraphQL schema +pub fn build_jobs_schema( + db: DatabaseConnection, +) -> async_graphql::Schema { + async_graphql::Schema::build(AdminQuery, AdminMutation, EmptySubscription) + .data(Arc::new(db)) + .finish() +} + +#[derive(Clone)] +pub struct SeaographyState { + pub schema: DynamicSchema, +} + +#[derive(Clone)] +pub struct JobsState { + pub schema: async_graphql::Schema, +} + +/// Seaography GraphQL POST handler for entity CRUD +async fn seaography_handler( + State(state): State>, + req: GraphQLRequest, +) -> GraphQLResponse { + state.schema.execute(req.into_inner()).await.into() +} + +/// Jobs GraphQL POST handler for job management +async fn jobs_handler( + State(state): State>, + req: GraphQLRequest, +) -> GraphQLResponse { + state.schema.execute(req.into_inner()).await.into() +} + +/// Seaography GraphQL playground (GraphiQL) handler +async fn seaography_playground() -> impl IntoResponse { + axum::response::Html( + async_graphql::http::GraphiQLSource::build() + .endpoint("/admin/graphql") + .finish(), + ) +} + +/// Jobs GraphQL playground (GraphiQL) handler +async fn jobs_playground() -> impl IntoResponse { + axum::response::Html( + async_graphql::http::GraphiQLSource::build() + .endpoint("/admin/jobs") + .finish(), + ) +} + +/// Create the admin API router with both Seaography and custom job endpoints +pub fn router( + seaography_schema: DynamicSchema, + jobs_schema: async_graphql::Schema, +) -> Router { + let seaography_state = Arc::new(SeaographyState { + schema: seaography_schema, + }); + let jobs_state = Arc::new(JobsState { + schema: jobs_schema, + }); + + Router::new() + // Seaography entity CRUD + .route("/admin/graphql", post(seaography_handler)) + .route("/admin/playground", get(seaography_playground)) + .with_state(seaography_state) + // Custom job management + .route("/admin/jobs", post(jobs_handler)) + .route("/admin/jobs/playground", get(jobs_playground)) + .with_state(jobs_state) +} diff --git a/src/admin_mutations.rs b/src/admin_mutations.rs new file mode 100644 index 0000000..4c45d64 --- /dev/null +++ b/src/admin_mutations.rs @@ -0,0 +1,132 @@ +use async_graphql::*; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; +use std::sync::Arc; + +use crate::jobs; + +/// Custom mutations for admin operations +#[derive(Default)] +pub struct AdminMutation; + +#[Object] +impl AdminMutation { + /// Manually trigger a background job by name + async fn trigger_job(&self, ctx: &Context<'_>, job_name: String) -> Result { + let db = ctx + .data::>() + .map_err(|_| Error::new("Database connection not available"))?; + + match jobs::trigger_job_manually(db.as_ref(), &job_name).await { + Ok(_) => Ok(JobTriggerResult { + success: true, + message: format!("Job '{}' triggered successfully", job_name), + job_name, + }), + Err(e) => Ok(JobTriggerResult { + success: false, + message: format!("Failed to trigger job '{}': {}", job_name, e), + job_name, + }), + } + } +} + +/// Result of triggering a job +#[derive(SimpleObject)] +pub struct JobTriggerResult { + pub success: bool, + pub message: String, + pub job_name: String, +} + +/// Custom queries for admin operations +#[derive(Default)] +pub struct AdminQuery; + +#[Object] +impl AdminQuery { + /// Get recent job executions with optional filtering + async fn job_logs( + &self, + ctx: &Context<'_>, + #[graphql(desc = "Filter by job name")] job_name: Option, + #[graphql(desc = "Limit number of results", default = 100)] limit: i64, + #[graphql(desc = "Only show failed jobs")] only_failures: Option, + ) -> Result> { + let db = ctx + .data::>() + .map_err(|_| Error::new("Database connection not available"))?; + + use crate::entities::job_execution::{Column, Entity}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; + + let mut query = Entity::find(); + + // Filter by job name if provided + if let Some(name) = job_name { + query = query.filter(Column::JobName.eq(name)); + } + + // Filter by failures if requested + if let Some(true) = only_failures { + query = query.filter(Column::Success.eq(0)); + } + + // Order by most recent first and limit + let results = query + .order_by_desc(Column::StartedAt) + .limit(limit as u64) + .all(db.as_ref()) + .await + .map_err(|e| Error::new(format!("Database error: {}", e)))?; + + Ok(results + .into_iter() + .map(|model| JobLog { + id: model.id, + job_name: model.job_name, + started_at: model.started_at, + completed_at: model.completed_at, + success: model.success, + error_message: model.error_message, + records_processed: model.records_processed, + }) + .collect()) + } + + /// Get list of available jobs that can be triggered + async fn available_jobs(&self) -> Result> { + Ok(vec![ + JobInfo { + name: "cleanup_expired_sessions".to_string(), + description: "Clean up expired user sessions".to_string(), + schedule: "Hourly at :00".to_string(), + }, + JobInfo { + name: "cleanup_expired_refresh_tokens".to_string(), + description: "Clean up expired refresh tokens".to_string(), + schedule: "Hourly at :30".to_string(), + }, + ]) + } +} + +/// Job log entry +#[derive(SimpleObject)] +pub struct JobLog { + pub id: i64, + pub job_name: String, + pub started_at: i64, + pub completed_at: Option, + pub success: Option, + pub error_message: Option, + pub records_processed: Option, +} + +/// Information about an available job +#[derive(SimpleObject)] +pub struct JobInfo { + pub name: String, + pub description: String, + pub schedule: String, +} diff --git a/src/entities/access_token.rs b/src/entities/access_token.rs new file mode 100644 index 0000000..2630ab1 --- /dev/null +++ b/src/entities/access_token.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "access_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub token: String, + pub client_id: String, + pub subject: String, + pub scope: String, + pub created_at: i64, + pub expires_at: i64, + pub revoked: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/auth_code.rs b/src/entities/auth_code.rs new file mode 100644 index 0000000..3e39dba --- /dev/null +++ b/src/entities/auth_code.rs @@ -0,0 +1,25 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "auth_codes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub code: String, + pub client_id: String, + pub redirect_uri: String, + pub scope: String, + pub subject: String, + pub nonce: Option, + pub code_challenge: String, + pub code_challenge_method: String, + pub created_at: i64, + pub expires_at: i64, + pub consumed: i64, + pub auth_time: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/client.rs b/src/entities/client.rs new file mode 100644 index 0000000..aa9e319 --- /dev/null +++ b/src/entities/client.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "clients")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub client_id: String, + pub client_secret: String, + pub client_name: Option, + pub redirect_uris: String, // JSON-encoded Vec + pub created_at: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/job_execution.rs b/src/entities/job_execution.rs new file mode 100644 index 0000000..621774d --- /dev/null +++ b/src/entities/job_execution.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "job_executions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = true)] + pub id: i64, + pub job_name: String, + pub started_at: i64, + pub completed_at: Option, + pub success: Option, // 0 = failure, 1 = success, NULL = running + pub error_message: Option, + pub records_processed: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/mod.rs b/src/entities/mod.rs new file mode 100644 index 0000000..6f69466 --- /dev/null +++ b/src/entities/mod.rs @@ -0,0 +1,17 @@ +pub mod access_token; +pub mod auth_code; +pub mod client; +pub mod job_execution; +pub mod property; +pub mod refresh_token; +pub mod session; +pub mod user; + +pub use access_token::Entity as AccessToken; +pub use auth_code::Entity as AuthCode; +pub use client::Entity as Client; +pub use job_execution::Entity as JobExecution; +pub use property::Entity as Property; +pub use refresh_token::Entity as RefreshToken; +pub use session::Entity as Session; +pub use user::Entity as User; diff --git a/src/entities/property.rs b/src/entities/property.rs new file mode 100644 index 0000000..3311a87 --- /dev/null +++ b/src/entities/property.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "properties")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub owner: String, + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + pub value: String, // JSON-encoded Value + pub updated_at: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/refresh_token.rs b/src/entities/refresh_token.rs new file mode 100644 index 0000000..44a0a5c --- /dev/null +++ b/src/entities/refresh_token.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "refresh_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub token: String, + pub client_id: String, + pub subject: String, + pub scope: String, + pub created_at: i64, + pub expires_at: i64, + pub revoked: i64, + pub parent_token: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/session.rs b/src/entities/session.rs new file mode 100644 index 0000000..00f0fc1 --- /dev/null +++ b/src/entities/session.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "sessions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub session_id: String, + pub subject: String, + pub auth_time: i64, + pub created_at: i64, + pub expires_at: i64, + pub user_agent: Option, + pub ip_address: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/user.rs b/src/entities/user.rs new file mode 100644 index 0000000..b92f446 --- /dev/null +++ b/src/entities/user.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub subject: String, + #[sea_orm(unique)] + pub username: String, + pub password_hash: String, + pub email: Option, + pub email_verified: i64, + pub created_at: i64, + pub enabled: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 0000000..e5b2234 --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,189 @@ +use crate::entities; +use crate::errors::CrabError; +use crate::storage; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, Set}; +use tokio_cron_scheduler::{Job, JobScheduler}; +use tracing::{error, info}; + +/// Initialize and start the job scheduler with all background tasks +pub async fn init_scheduler(db: DatabaseConnection) -> Result { + let sched = JobScheduler::new() + .await + .map_err(|e| CrabError::Other(format!("Failed to create job scheduler: {}", e)))?; + + let db_clone = db.clone(); + + // Cleanup expired sessions job - runs every hour + let cleanup_sessions_job = Job::new_async("0 0 * * * *", move |_uuid, _l| { + let db = db_clone.clone(); + Box::pin(async move { + info!("Running cleanup_expired_sessions job"); + let execution_id = start_job_execution(&db, "cleanup_expired_sessions") + .await + .ok(); + + match storage::cleanup_expired_sessions(&db).await { + Ok(count) => { + info!("Cleaned up {} expired sessions", count); + if let Some(id) = execution_id { + let _ = complete_job_execution(&db, id, true, None, Some(count as i64)) + .await; + } + } + Err(e) => { + error!("Failed to cleanup expired sessions: {}", e); + if let Some(id) = execution_id { + let _ = complete_job_execution( + &db, + id, + false, + Some(e.to_string()), + None, + ) + .await; + } + } + } + }) + }) + .map_err(|e| CrabError::Other(format!("Failed to create cleanup sessions job: {}", e)))?; + + sched + .add(cleanup_sessions_job) + .await + .map_err(|e| CrabError::Other(format!("Failed to add cleanup sessions job: {}", e)))?; + + let db_clone = db.clone(); + + // Cleanup expired refresh tokens job - runs every hour at 30 minutes past + let cleanup_tokens_job = Job::new_async("0 30 * * * *", move |_uuid, _l| { + let db = db_clone.clone(); + Box::pin(async move { + info!("Running cleanup_expired_refresh_tokens job"); + let execution_id = start_job_execution(&db, "cleanup_expired_refresh_tokens") + .await + .ok(); + + match storage::cleanup_expired_refresh_tokens(&db).await { + Ok(count) => { + info!("Cleaned up {} expired refresh tokens", count); + if let Some(id) = execution_id { + let _ = complete_job_execution(&db, id, true, None, Some(count as i64)) + .await; + } + } + Err(e) => { + error!("Failed to cleanup expired refresh tokens: {}", e); + if let Some(id) = execution_id { + let _ = complete_job_execution( + &db, + id, + false, + Some(e.to_string()), + None, + ) + .await; + } + } + } + }) + }) + .map_err(|e| CrabError::Other(format!("Failed to create cleanup tokens job: {}", e)))?; + + sched + .add(cleanup_tokens_job) + .await + .map_err(|e| CrabError::Other(format!("Failed to add cleanup tokens job: {}", e)))?; + + // Start the scheduler + sched + .start() + .await + .map_err(|e| CrabError::Other(format!("Failed to start job scheduler: {}", e)))?; + + info!("Job scheduler started with {} jobs", 2); + + Ok(sched) +} + +/// Record the start of a job execution +pub async fn start_job_execution( + db: &DatabaseConnection, + job_name: &str, +) -> Result { + use entities::job_execution; + + let now = Utc::now().timestamp(); + + let execution = job_execution::ActiveModel { + id: Set(0), // Will be auto-generated + job_name: Set(job_name.to_string()), + started_at: Set(now), + completed_at: Set(None), + success: Set(None), + error_message: Set(None), + records_processed: Set(None), + }; + + let result = execution.insert(db).await?; + Ok(result.id) +} + +/// Record the completion of a job execution +pub async fn complete_job_execution( + db: &DatabaseConnection, + execution_id: i64, + success: bool, + error_message: Option, + records_processed: Option, +) -> Result<(), CrabError> { + use entities::job_execution::{Column, Entity}; + + let now = Utc::now().timestamp(); + + if let Some(execution) = Entity::find() + .filter(Column::Id.eq(execution_id)) + .one(db) + .await? + { + let mut active: entities::job_execution::ActiveModel = execution.into_active_model(); + active.completed_at = Set(Some(now)); + active.success = Set(Some(if success { 1 } else { 0 })); + active.error_message = Set(error_message); + active.records_processed = Set(records_processed); + active.update(db).await?; + } + + Ok(()) +} + +/// Manually trigger a job by name (useful for admin API) +pub async fn trigger_job_manually( + db: &DatabaseConnection, + job_name: &str, +) -> Result<(), CrabError> { + info!("Manually triggering job: {}", job_name); + let execution_id = start_job_execution(db, job_name).await?; + + let result = match job_name { + "cleanup_expired_sessions" => storage::cleanup_expired_sessions(db).await, + "cleanup_expired_refresh_tokens" => storage::cleanup_expired_refresh_tokens(db).await, + _ => { + return Err(CrabError::Other(format!("Unknown job name: {}", job_name))); + } + }; + + match result { + Ok(count) => { + info!("Manually triggered job {} completed: {} records", job_name, count); + complete_job_execution(db, execution_id, true, None, Some(count as i64)).await?; + } + Err(e) => { + error!("Manually triggered job {} failed: {}", job_name, e); + complete_job_execution(db, execution_id, false, Some(e.to_string()), None).await?; + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9c5c127..46f814b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,13 @@ +mod admin_graphql; +mod admin_mutations; +mod entities; mod errors; +mod jobs; mod jwks; mod session; mod settings; mod storage; +mod user_sync; mod web; use clap::Parser; @@ -19,6 +24,19 @@ struct Cli { /// Path to configuration file #[arg(short, long, default_value = "config.toml")] config: String, + + #[command(subcommand)] + command: Option, +} + +#[derive(Parser, Debug)] +enum Command { + /// Sync users from a JSON file (idempotent) + SyncUsers { + /// Path to JSON file containing users + #[arg(short, long)] + file: String, + }, } #[tokio::main] @@ -36,14 +54,34 @@ async fn main() -> Result<()> { // init storage (database) let db = storage::init(&settings.database).await?; - // ensure test users exist - ensure_test_users(&db).await?; + // Handle subcommands + match cli.command { + Some(Command::SyncUsers { file }) => { + // Run user sync and exit + user_sync::sync_users_from_file(&db, &file).await?; + tracing::info!("User sync completed successfully"); + return Ok(()); + } + None => { + // Normal server startup + // ensure test users exist + ensure_test_users(&db).await?; - // init jwks (generate if missing) - let jwks_mgr = jwks::JwksManager::new(settings.keys.clone()).await?; + // init jwks (generate if missing) + let jwks_mgr = jwks::JwksManager::new(settings.keys.clone()).await?; + + // build admin GraphQL schemas + let seaography_schema = admin_graphql::build_seaography_schema(db.clone()); + let jobs_schema = admin_graphql::build_jobs_schema(db.clone()); + + // init and start background job scheduler + let _scheduler = jobs::init_scheduler(db.clone()).await?; + + // start web server (includes both public and admin servers) + web::serve(settings, db, jwks_mgr, seaography_schema, jobs_schema).await?; + } + } - // start web server - web::serve(settings, db, jwks_mgr).await?; Ok(()) } diff --git a/src/settings.rs b/src/settings.rs index ae4382e..225ffe4 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -16,11 +16,23 @@ pub struct Server { pub port: u16, /// If set, this is used as the issuer/public base URL, e.g., https://idp.example.com pub public_base_url: Option, + /// Enable public user registration. If false, only admin API can create users. + #[serde(default = "default_allow_public_registration")] + pub allow_public_registration: bool, + /// Admin GraphQL API port (defaults to port + 1) + pub admin_port: Option, +} + +fn default_allow_public_registration() -> bool { + false // Secure by default - registration disabled } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Database { - /// SeaORM/SQLx connection string, e.g., sqlite://barycenter.db?mode=rwc + /// SeaORM/SQLx connection string + /// Examples: + /// - SQLite: sqlite://barycenter.db?mode=rwc + /// - PostgreSQL: postgresql://user:password@localhost/barycenter pub url: String, } @@ -48,6 +60,8 @@ impl Default for Server { host: "0.0.0.0".to_string(), port: 8080, public_base_url: None, + allow_public_registration: false, + admin_port: None, // Defaults to port + 1 if not set } } } diff --git a/src/storage.rs b/src/storage.rs index bff9f36..e15d40a 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,9 +1,13 @@ +use crate::entities; use crate::errors::CrabError; use crate::settings::Database as DbCfg; use base64ct::Encoding; use chrono::Utc; use rand::RngCore; -use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, Database, + DatabaseConnection, DbBackend, EntityTrait, QueryFilter, Set, Statement, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -83,37 +87,50 @@ pub struct RefreshToken { pub parent_token: Option, // For token rotation tracking } +fn detect_backend(url: &str) -> DbBackend { + if url.starts_with("postgres://") || url.starts_with("postgresql://") { + DbBackend::Postgres + } else { + DbBackend::Sqlite + } +} + pub async fn init(cfg: &DbCfg) -> Result { let db = Database::connect(&cfg.url).await?; + let backend = detect_backend(&cfg.url); + // bootstrap schema - db.execute(Statement::from_string( - DbBackend::Sqlite, - "PRAGMA foreign_keys = ON", - )) - .await?; + // Enable foreign keys for SQLite only + if backend == DbBackend::Sqlite { + db.execute(Statement::from_string( + DbBackend::Sqlite, + "PRAGMA foreign_keys = ON", + )) + .await?; + } db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS clients ( client_id TEXT PRIMARY KEY, client_secret TEXT NOT NULL, client_name TEXT, redirect_uris TEXT NOT NULL, - created_at INTEGER NOT NULL + created_at BIGINT NOT NULL ) "#, )) .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS properties ( owner TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, - updated_at INTEGER NOT NULL, + updated_at BIGINT NOT NULL, PRIMARY KEY(owner, key) ) "#, @@ -121,7 +138,7 @@ pub async fn init(cfg: &DbCfg) -> Result { .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS auth_codes ( code TEXT PRIMARY KEY, @@ -132,56 +149,56 @@ pub async fn init(cfg: &DbCfg) -> Result { nonce TEXT, code_challenge TEXT NOT NULL, code_challenge_method TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - consumed INTEGER NOT NULL DEFAULT 0, - auth_time INTEGER + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + consumed BIGINT NOT NULL DEFAULT 0, + auth_time BIGINT ) "#, )) .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS access_tokens ( token TEXT PRIMARY KEY, client_id TEXT NOT NULL, subject TEXT NOT NULL, scope TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - revoked INTEGER NOT NULL DEFAULT 0 + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + revoked BIGINT NOT NULL DEFAULT 0 ) "#, )) .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS users ( subject TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email TEXT, - email_verified INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1 + email_verified BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + enabled BIGINT NOT NULL DEFAULT 1 ) "#, )) .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, subject TEXT NOT NULL, - auth_time INTEGER NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, + auth_time BIGINT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, user_agent TEXT, ip_address TEXT ) @@ -190,22 +207,22 @@ pub async fn init(cfg: &DbCfg) -> Result { .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, "CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)", )) .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, r#" CREATE TABLE IF NOT EXISTS refresh_tokens ( token TEXT PRIMARY KEY, client_id TEXT NOT NULL, subject TEXT NOT NULL, scope TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - revoked INTEGER NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + revoked BIGINT NOT NULL DEFAULT 0, parent_token TEXT ) "#, @@ -213,11 +230,41 @@ pub async fn init(cfg: &DbCfg) -> Result { .await?; db.execute(Statement::from_string( - DbBackend::Sqlite, + backend, "CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at)", )) .await?; + // Job executions table for tracking background job runs + let id_type = match backend { + DbBackend::Postgres => "BIGSERIAL PRIMARY KEY", + _ => "INTEGER PRIMARY KEY AUTOINCREMENT", + }; + db.execute(Statement::from_string( + backend, + format!( + r#" + CREATE TABLE IF NOT EXISTS job_executions ( + id {}, + job_name TEXT NOT NULL, + started_at BIGINT NOT NULL, + completed_at BIGINT, + success BIGINT, + error_message TEXT, + records_processed BIGINT + ) + "#, + id_type + ), + )) + .await?; + + db.execute(Statement::from_string( + backend, + "CREATE INDEX IF NOT EXISTS idx_job_executions_started ON job_executions(started_at)", + )) + .await?; + Ok(db) } @@ -227,19 +274,15 @@ pub async fn create_client(db: &DatabaseConnection, input: NewClient) -> Result< let created_at = Utc::now().timestamp(); let redirect_uris_json = serde_json::to_string(&input.redirect_uris)?; - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO clients (client_id, client_secret, client_name, redirect_uris, created_at) - VALUES (?, ?, ?, ?, ?)"#, - [ - client_id.clone().into(), - client_secret.clone().into(), - input.client_name.clone().into(), - redirect_uris_json.into(), - created_at.into(), - ], - )) - .await?; + let client = entities::client::ActiveModel { + client_id: Set(client_id.clone()), + client_secret: Set(client_secret.clone()), + client_name: Set(input.client_name.clone()), + redirect_uris: Set(redirect_uris_json), + created_at: Set(created_at), + }; + + client.insert(db).await?; Ok(Client { client_id, @@ -255,16 +298,15 @@ pub async fn get_property( owner: &str, key: &str, ) -> Result, CrabError> { - if let Some(row) = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - "SELECT value FROM properties WHERE owner = ? AND key = ?", - [owner.into(), key.into()], - )) + use entities::property::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::Owner.eq(owner)) + .filter(Column::Key.eq(key)) + .one(db) .await? { - let value_str: String = row.try_get("", "value").unwrap_or_default(); - let json: Value = serde_json::from_str(&value_str)?; + let json: Value = serde_json::from_str(&model.value)?; Ok(Some(json)) } else { Ok(None) @@ -277,16 +319,28 @@ pub async fn set_property( key: &str, value: &Value, ) -> Result<(), CrabError> { + use entities::property::{Column, Entity}; + use sea_orm::sea_query::OnConflict; + let now = Utc::now().timestamp(); let json = serde_json::to_string(value)?; - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO properties (owner, key, value, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(owner, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"#, - [owner.into(), key.into(), json.into(), now.into()], - )) - .await?; + + let property = entities::property::ActiveModel { + owner: Set(owner.to_string()), + key: Set(key.to_string()), + value: Set(json.clone()), + updated_at: Set(now), + }; + + Entity::insert(property) + .on_conflict( + OnConflict::columns([Column::Owner, Column::Key]) + .update_columns([Column::Value, Column::UpdatedAt]) + .to_owned(), + ) + .exec(db) + .await?; + Ok(()) } @@ -294,21 +348,21 @@ pub async fn get_client( db: &DatabaseConnection, client_id: &str, ) -> Result, CrabError> { - if let Some(row) = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"SELECT client_id, client_secret, client_name, redirect_uris, created_at FROM clients WHERE client_id = ?"#, - [client_id.into()], - )) + use entities::client::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::ClientId.eq(client_id)) + .one(db) .await? { - let client_id: String = row.try_get("", "client_id").unwrap_or_default(); - let client_secret: String = row.try_get("", "client_secret").unwrap_or_default(); - let client_name: Option = row.try_get("", "client_name").ok(); - let redirect_uris_json: String = row.try_get("", "redirect_uris").unwrap_or_default(); - let redirect_uris: Vec = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); - let created_at: i64 = row.try_get("", "created_at").unwrap_or_default(); - Ok(Some(Client { client_id, client_secret, client_name, redirect_uris, created_at })) + let redirect_uris: Vec = serde_json::from_str(&model.redirect_uris)?; + Ok(Some(Client { + client_id: model.client_id, + client_secret: model.client_secret, + client_name: model.client_name, + redirect_uris, + created_at: model.created_at, + })) } else { Ok(None) } @@ -329,25 +383,24 @@ pub async fn issue_auth_code( let code = random_id(); let now = Utc::now().timestamp(); let expires_at = now + ttl_secs; - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO auth_codes (code, client_id, redirect_uri, scope, subject, nonce, code_challenge, code_challenge_method, created_at, expires_at, consumed, auth_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)"#, - [ - code.clone().into(), - client_id.into(), - redirect_uri.into(), - scope.into(), - subject.into(), - nonce.clone().into(), - code_challenge.into(), - code_challenge_method.into(), - now.into(), - expires_at.into(), - auth_time.into(), - ], - )) - .await?; + + let auth_code = entities::auth_code::ActiveModel { + code: Set(code.clone()), + client_id: Set(client_id.to_string()), + redirect_uri: Set(redirect_uri.to_string()), + scope: Set(scope.to_string()), + subject: Set(subject.to_string()), + nonce: Set(nonce.clone()), + code_challenge: Set(code_challenge.to_string()), + code_challenge_method: Set(code_challenge_method.to_string()), + created_at: Set(now), + expires_at: Set(expires_at), + consumed: Set(0), + auth_time: Set(auth_time), + }; + + auth_code.insert(db).await?; + Ok(AuthCode { code, client_id: client_id.to_string(), @@ -368,42 +421,37 @@ pub async fn consume_auth_code( db: &DatabaseConnection, code: &str, ) -> Result, CrabError> { - if let Some(row) = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"SELECT code, client_id, redirect_uri, scope, subject, nonce, code_challenge, code_challenge_method, created_at, expires_at, consumed, auth_time - FROM auth_codes WHERE code = ?"#, - [code.into()], - )) + use entities::auth_code::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::Code.eq(code)) + .one(db) .await? { - let consumed: i64 = row.try_get("", "consumed").unwrap_or_default(); - let expires_at: i64 = row.try_get("", "expires_at").unwrap_or_default(); let now = Utc::now().timestamp(); - if consumed != 0 || now > expires_at { + if model.consumed != 0 || now > model.expires_at { return Ok(None); } // Mark as consumed - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"UPDATE auth_codes SET consumed = ? WHERE code = ?"#, - [1.into(), code.into()], - )) - .await?; + let mut active_model: entities::auth_code::ActiveModel = model.clone().into(); + active_model.consumed = Set(1); + active_model.update(db).await?; - let code_val: String = row.try_get("", "code").unwrap_or_default(); - let client_id: String = row.try_get("", "client_id").unwrap_or_default(); - let redirect_uri: String = row.try_get("", "redirect_uri").unwrap_or_default(); - let scope: String = row.try_get("", "scope").unwrap_or_default(); - let subject: String = row.try_get("", "subject").unwrap_or_default(); - let nonce: Option = row.try_get("", "nonce").ok(); - let code_challenge: String = row.try_get("", "code_challenge").unwrap_or_default(); - let code_challenge_method: String = row.try_get("", "code_challenge_method").unwrap_or_default(); - let created_at: i64 = row.try_get("", "created_at").unwrap_or_default(); - let expires_at: i64 = row.try_get("", "expires_at").unwrap_or_default(); - let auth_time: Option = row.try_get("", "auth_time").ok(); - Ok(Some(AuthCode { code: code_val, client_id, redirect_uri, scope, subject, nonce, code_challenge, code_challenge_method, created_at, expires_at, consumed: 1, auth_time })) + Ok(Some(AuthCode { + code: model.code, + client_id: model.client_id, + redirect_uri: model.redirect_uri, + scope: model.scope, + subject: model.subject, + nonce: model.nonce, + code_challenge: model.code_challenge, + code_challenge_method: model.code_challenge_method, + created_at: model.created_at, + expires_at: model.expires_at, + consumed: 1, + auth_time: model.auth_time, + })) } else { Ok(None) } @@ -419,13 +467,19 @@ pub async fn issue_access_token( let token = random_id(); let now = Utc::now().timestamp(); let expires_at = now + ttl_secs; - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO access_tokens (token, client_id, subject, scope, created_at, expires_at, revoked) - VALUES (?, ?, ?, ?, ?, ?, 0)"#, - [token.clone().into(), client_id.into(), subject.into(), scope.into(), now.into(), expires_at.into()], - )) - .await?; + + let access_token = entities::access_token::ActiveModel { + token: Set(token.clone()), + client_id: Set(client_id.to_string()), + subject: Set(subject.to_string()), + scope: Set(scope.to_string()), + created_at: Set(now), + expires_at: Set(expires_at), + revoked: Set(0), + }; + + access_token.insert(db).await?; + Ok(AccessToken { token, client_id: client_id.to_string(), @@ -441,24 +495,27 @@ pub async fn get_access_token( db: &DatabaseConnection, token: &str, ) -> Result, CrabError> { - if let Some(row) = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"SELECT token, client_id, subject, scope, created_at, expires_at, revoked FROM access_tokens WHERE token = ?"#, - [token.into()], - )) + use entities::access_token::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::Token.eq(token)) + .one(db) .await? { - let revoked: i64 = row.try_get("", "revoked").unwrap_or_default(); - let expires_at: i64 = row.try_get("", "expires_at").unwrap_or_default(); let now = Utc::now().timestamp(); - if revoked != 0 || now > expires_at { return Ok(None); } - let token: String = row.try_get("", "token").unwrap_or_default(); - let client_id: String = row.try_get("", "client_id").unwrap_or_default(); - let subject: String = row.try_get("", "subject").unwrap_or_default(); - let scope: String = row.try_get("", "scope").unwrap_or_default(); - let created_at: i64 = row.try_get("", "created_at").unwrap_or_default(); - Ok(Some(AccessToken { token, client_id, subject, scope, created_at, expires_at, revoked })) + if model.revoked != 0 || now > model.expires_at { + return Ok(None); + } + + Ok(Some(AccessToken { + token: model.token, + client_id: model.client_id, + subject: model.subject, + scope: model.scope, + created_at: model.created_at, + expires_at: model.expires_at, + revoked: model.revoked, + })) } else { Ok(None) } @@ -492,19 +549,17 @@ pub async fn create_user( .map_err(|e| CrabError::Other(format!("Password hashing failed: {}", e)))? .to_string(); - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO users (subject, username, password_hash, email, email_verified, created_at, enabled) - VALUES (?, ?, ?, ?, 0, ?, 1)"#, - [ - subject.clone().into(), - username.into(), - password_hash.clone().into(), - email.clone().into(), - created_at.into(), - ], - )) - .await?; + let user = entities::user::ActiveModel { + subject: Set(subject.clone()), + username: Set(username.to_string()), + password_hash: Set(password_hash.clone()), + email: Set(email.clone()), + email_verified: Set(0), + created_at: Set(created_at), + enabled: Set(1), + }; + + user.insert(db).await?; Ok(User { subject, @@ -521,31 +576,21 @@ pub async fn get_user_by_username( db: &DatabaseConnection, username: &str, ) -> Result, CrabError> { - if let Some(row) = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"SELECT subject, username, password_hash, email, email_verified, created_at, enabled - FROM users WHERE username = ?"#, - [username.into()], - )) + use entities::user::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::Username.eq(username)) + .one(db) .await? { - let subject: String = row.try_get("", "subject").unwrap_or_default(); - let username: String = row.try_get("", "username").unwrap_or_default(); - let password_hash: String = row.try_get("", "password_hash").unwrap_or_default(); - let email: Option = row.try_get("", "email").ok(); - let email_verified: i64 = row.try_get("", "email_verified").unwrap_or_default(); - let created_at: i64 = row.try_get("", "created_at").unwrap_or_default(); - let enabled: i64 = row.try_get("", "enabled").unwrap_or_default(); - Ok(Some(User { - subject, - username, - password_hash, - email, - email_verified, - created_at, - enabled, + subject: model.subject, + username: model.username, + password_hash: model.password_hash, + email: model.email, + email_verified: model.email_verified, + created_at: model.created_at, + enabled: model.enabled, })) } else { Ok(None) @@ -577,6 +622,54 @@ pub async fn verify_user_password( } } +/// Update user enabled and email_verified flags +pub async fn update_user( + db: &DatabaseConnection, + username: &str, + enabled: bool, + email_verified: bool, +) -> Result<(), CrabError> { + use entities::user::{Column, Entity}; + + // Find the user + let user = Entity::find() + .filter(Column::Username.eq(username)) + .one(db) + .await? + .ok_or_else(|| CrabError::Other(format!("User not found: {}", username)))?; + + // Update the user + let mut active: entities::user::ActiveModel = user.into(); + active.enabled = Set(if enabled { 1 } else { 0 }); + active.email_verified = Set(if email_verified { 1 } else { 0 }); + active.update(db).await?; + + Ok(()) +} + +/// Update user email +pub async fn update_user_email( + db: &DatabaseConnection, + username: &str, + email: Option, +) -> Result<(), CrabError> { + use entities::user::{Column, Entity}; + + // Find the user + let user = Entity::find() + .filter(Column::Username.eq(username)) + .one(db) + .await? + .ok_or_else(|| CrabError::Other(format!("User not found: {}", username)))?; + + // Update the user + let mut active: entities::user::ActiveModel = user.into(); + active.email = Set(email); + active.update(db).await?; + + Ok(()) +} + // Session management functions pub async fn create_session( @@ -590,21 +683,17 @@ pub async fn create_session( let now = Utc::now().timestamp(); let expires_at = now + ttl_secs; - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO sessions (session_id, subject, auth_time, created_at, expires_at, user_agent, ip_address) - VALUES (?, ?, ?, ?, ?, ?, ?)"#, - [ - session_id.clone().into(), - subject.into(), - now.into(), - now.into(), - expires_at.into(), - user_agent.clone().into(), - ip_address.clone().into(), - ], - )) - .await?; + let session = entities::session::ActiveModel { + session_id: Set(session_id.clone()), + subject: Set(subject.to_string()), + auth_time: Set(now), + created_at: Set(now), + expires_at: Set(expires_at), + user_agent: Set(user_agent.clone()), + ip_address: Set(ip_address.clone()), + }; + + session.insert(db).await?; Ok(Session { session_id, @@ -621,37 +710,27 @@ pub async fn get_session( db: &DatabaseConnection, session_id: &str, ) -> Result, CrabError> { - if let Some(row) = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"SELECT session_id, subject, auth_time, created_at, expires_at, user_agent, ip_address - FROM sessions WHERE session_id = ?"#, - [session_id.into()], - )) + use entities::session::{Column, Entity}; + + if let Some(model) = Entity::find() + .filter(Column::SessionId.eq(session_id)) + .one(db) .await? { - let session_id: String = row.try_get("", "session_id").unwrap_or_default(); - let subject: String = row.try_get("", "subject").unwrap_or_default(); - let auth_time: i64 = row.try_get("", "auth_time").unwrap_or_default(); - let created_at: i64 = row.try_get("", "created_at").unwrap_or_default(); - let expires_at: i64 = row.try_get("", "expires_at").unwrap_or_default(); - let user_agent: Option = row.try_get("", "user_agent").ok(); - let ip_address: Option = row.try_get("", "ip_address").ok(); - // Check if session is expired let now = Utc::now().timestamp(); - if now > expires_at { + if now > model.expires_at { return Ok(None); } Ok(Some(Session { - session_id, - subject, - auth_time, - created_at, - expires_at, - user_agent, - ip_address, + session_id: model.session_id, + subject: model.subject, + auth_time: model.auth_time, + created_at: model.created_at, + expires_at: model.expires_at, + user_agent: model.user_agent, + ip_address: model.ip_address, })) } else { Ok(None) @@ -659,25 +738,26 @@ pub async fn get_session( } pub async fn delete_session(db: &DatabaseConnection, session_id: &str) -> Result<(), CrabError> { - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - "DELETE FROM sessions WHERE session_id = ?", - [session_id.into()], - )) - .await?; + use entities::session::{Column, Entity}; + + Entity::delete_many() + .filter(Column::SessionId.eq(session_id)) + .exec(db) + .await?; + Ok(()) } pub async fn cleanup_expired_sessions(db: &DatabaseConnection) -> Result { + use entities::session::{Column, Entity}; + let now = Utc::now().timestamp(); - let result = db - .execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - "DELETE FROM sessions WHERE expires_at < ?", - [now.into()], - )) + let result = Entity::delete_many() + .filter(Column::ExpiresAt.lt(now)) + .exec(db) .await?; - Ok(result.rows_affected()) + + Ok(result.rows_affected) } // Refresh Token Functions @@ -694,21 +774,18 @@ pub async fn issue_refresh_token( let now = Utc::now().timestamp(); let expires_at = now + ttl_secs; - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"INSERT INTO refresh_tokens (token, client_id, subject, scope, created_at, expires_at, revoked, parent_token) - VALUES (?, ?, ?, ?, ?, ?, 0, ?)"#, - [ - token.clone().into(), - client_id.into(), - subject.into(), - scope.into(), - now.into(), - expires_at.into(), - parent_token.clone().into(), - ], - )) - .await?; + let refresh_token = entities::refresh_token::ActiveModel { + token: Set(token.clone()), + client_id: Set(client_id.to_string()), + subject: Set(subject.to_string()), + scope: Set(scope.to_string()), + created_at: Set(now), + expires_at: Set(expires_at), + revoked: Set(0), + parent_token: Set(parent_token.clone()), + }; + + refresh_token.insert(db).await?; Ok(RefreshToken { token, @@ -726,40 +803,28 @@ pub async fn get_refresh_token( db: &DatabaseConnection, token: &str, ) -> Result, CrabError> { - let result = db - .query_one(Statement::from_sql_and_values( - DbBackend::Sqlite, - r#"SELECT token, client_id, subject, scope, created_at, expires_at, revoked, parent_token - FROM refresh_tokens WHERE token = ?"#, - [token.into()], - )) - .await?; - - if let Some(row) = result { - let token: String = row.try_get("", "token")?; - let client_id: String = row.try_get("", "client_id")?; - let subject: String = row.try_get("", "subject")?; - let scope: String = row.try_get("", "scope")?; - let created_at: i64 = row.try_get("", "created_at")?; - let expires_at: i64 = row.try_get("", "expires_at")?; - let revoked: i64 = row.try_get("", "revoked")?; - let parent_token: Option = row.try_get("", "parent_token").ok(); + use entities::refresh_token::{Column, Entity}; + if let Some(model) = Entity::find() + .filter(Column::Token.eq(token)) + .one(db) + .await? + { // Check if token is expired or revoked let now = Utc::now().timestamp(); - if revoked != 0 || now > expires_at { + if model.revoked != 0 || now > model.expires_at { return Ok(None); } Ok(Some(RefreshToken { - token, - client_id, - subject, - scope, - created_at, - expires_at, - revoked, - parent_token, + token: model.token, + client_id: model.client_id, + subject: model.subject, + scope: model.scope, + created_at: model.created_at, + expires_at: model.expires_at, + revoked: model.revoked, + parent_token: model.parent_token, })) } else { Ok(None) @@ -767,12 +832,19 @@ pub async fn get_refresh_token( } pub async fn revoke_refresh_token(db: &DatabaseConnection, token: &str) -> Result<(), CrabError> { - db.execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - "UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", - [token.into()], - )) - .await?; + use entities::refresh_token::{Column, Entity}; + + // Find the token and update it + if let Some(model) = Entity::find() + .filter(Column::Token.eq(token)) + .one(db) + .await? + { + let mut active_model: entities::refresh_token::ActiveModel = model.into(); + active_model.revoked = Set(1); + active_model.update(db).await?; + } + Ok(()) } @@ -800,13 +872,13 @@ pub async fn rotate_refresh_token( } pub async fn cleanup_expired_refresh_tokens(db: &DatabaseConnection) -> Result { + use entities::refresh_token::{Column, Entity}; + let now = Utc::now().timestamp(); - let result = db - .execute(Statement::from_sql_and_values( - DbBackend::Sqlite, - "DELETE FROM refresh_tokens WHERE expires_at < ?", - [now.into()], - )) + let result = Entity::delete_many() + .filter(Column::ExpiresAt.lt(now)) + .exec(db) .await?; - Ok(result.rows_affected()) + + Ok(result.rows_affected) } diff --git a/src/user_sync.rs b/src/user_sync.rs new file mode 100644 index 0000000..ef341a3 --- /dev/null +++ b/src/user_sync.rs @@ -0,0 +1,184 @@ +use crate::errors::CrabError; +use crate::storage; +use miette::{IntoDiagnostic, Result}; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; + +/// User definition from JSON file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserDefinition { + /// Username (unique identifier) + pub username: String, + /// User email + #[serde(default)] + pub email: Option, + /// Plain text password (will be hashed) + pub password: String, + /// Whether the user account is enabled + #[serde(default = "default_true")] + pub enabled: bool, + /// Whether the email is verified + #[serde(default)] + pub email_verified: bool, + /// Custom properties to attach to the user + #[serde(default)] + pub properties: HashMap, +} + +fn default_true() -> bool { + true +} + +/// Root structure of the users JSON file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsersFile { + pub users: Vec, +} + +/// Sync users from a JSON file to the database (idempotent) +pub async fn sync_users_from_file(db: &DatabaseConnection, file_path: &str) -> Result<()> { + tracing::info!("Loading users from {}", file_path); + + // Read and parse JSON file + let content = fs::read_to_string(file_path) + .into_diagnostic() + .map_err(|e| { + miette::miette!( + "Failed to read users file at '{}': {}", + file_path, + e + ) + })?; + + let users_file: UsersFile = serde_json::from_str(&content) + .into_diagnostic() + .map_err(|e| { + miette::miette!( + "Failed to parse users JSON file: {}\n\nExpected format:\n{{\n \"users\": [\n {{\n \"username\": \"alice\",\n \"email\": \"alice@example.com\",\n \"password\": \"secure-password\",\n \"enabled\": true,\n \"email_verified\": false,\n \"properties\": {{\n \"department\": \"Engineering\"\n }}\n }}\n ]\n}}", + e + ) + })?; + + tracing::info!("Found {} user(s) in file", users_file.users.len()); + + let mut created = 0; + let mut updated = 0; + let mut unchanged = 0; + + for user_def in users_file.users { + match sync_user(db, &user_def).await? { + SyncResult::Created => created += 1, + SyncResult::Updated => updated += 1, + SyncResult::Unchanged => unchanged += 1, + } + } + + tracing::info!( + "User sync complete: {} created, {} updated, {} unchanged", + created, + updated, + unchanged + ); + + Ok(()) +} + +#[derive(Debug)] +enum SyncResult { + Created, + Updated, + Unchanged, +} + +/// Sync a single user (idempotent) +async fn sync_user(db: &DatabaseConnection, user_def: &UserDefinition) -> Result { + // Check if user exists + let existing = storage::get_user_by_username(db, &user_def.username) + .await + .into_diagnostic()?; + + let result = match existing { + None => { + // Create new user + tracing::info!("Creating user: {}", user_def.username); + storage::create_user( + db, + &user_def.username, + &user_def.password, + user_def.email.clone(), + ) + .await + .into_diagnostic()?; + + // Update enabled and email_verified flags if needed + if !user_def.enabled || user_def.email_verified { + storage::update_user( + db, + &user_def.username, + user_def.enabled, + user_def.email_verified, + ) + .await + .into_diagnostic()?; + } + + SyncResult::Created + } + Some(existing_user) => { + // User exists - check if update is needed + let enabled_matches = (existing_user.enabled == 1) == user_def.enabled; + let email_verified_matches = + (existing_user.email_verified == 1) == user_def.email_verified; + let email_matches = existing_user.email == user_def.email; + + if !enabled_matches || !email_verified_matches || !email_matches { + tracing::info!("Updating user: {}", user_def.username); + storage::update_user( + db, + &user_def.username, + user_def.enabled, + user_def.email_verified, + ) + .await + .into_diagnostic()?; + + // Update email if it changed + if !email_matches { + storage::update_user_email(db, &user_def.username, user_def.email.clone()) + .await + .into_diagnostic()?; + } + + SyncResult::Updated + } else { + SyncResult::Unchanged + } + } + }; + + // Sync properties + for (key, value) in &user_def.properties { + // Get the user's subject to use as owner for properties + let user = storage::get_user_by_username(db, &user_def.username) + .await + .into_diagnostic()? + .ok_or_else(|| miette::miette!("User not found after creation: {}", user_def.username))?; + + storage::set_property(db, &user.subject, key, value) + .await + .into_diagnostic()?; + } + + if !user_def.properties.is_empty() { + tracing::debug!( + "Synced {} properties for user {}", + user_def.properties.len(), + user_def.username + ); + } + + Ok(result) +} diff --git a/src/web.rs b/src/web.rs index 40d8860..71c6f90 100644 --- a/src/web.rs +++ b/src/web.rs @@ -81,6 +81,12 @@ pub async fn serve( settings: Settings, db: DatabaseConnection, jwks: JwksManager, + seaography_schema: async_graphql::dynamic::Schema, + jobs_schema: async_graphql::Schema< + crate::admin_mutations::AdminQuery, + crate::admin_mutations::AdminMutation, + async_graphql::EmptySubscription, + >, ) -> miette::Result<()> { let state = AppState { settings: Arc::new(settings), @@ -95,30 +101,66 @@ pub async fn serve( // - Login endpoint: 5 attempts/min per IP // - Authorize endpoint: 20 req/min per IP - let router = Router::new() + let mut router = Router::new() .route("/.well-known/openid-configuration", get(discovery)) .route("/.well-known/jwks.json", get(jwks_handler)) .route("/connect/register", post(register_client)) - .route("/properties/{owner}/{key}", get(get_property)) + .route("/properties/{owner}/{key}", get(get_property).put(set_property)) .route("/federation/trust-anchors", get(trust_anchors)) - .route("/register", post(register_user)) .route("/login", get(login_page).post(login_submit)) .route("/logout", get(logout)) .route("/authorize", get(authorize)) .route("/token", post(token)) - .route("/userinfo", get(userinfo)) + .route("/userinfo", get(userinfo)); + + // Conditionally add public registration route + if state.settings.server.allow_public_registration { + tracing::info!("Public user registration is ENABLED"); + router = router.route("/register", post(register_user)); + } else { + tracing::info!("Public user registration is DISABLED - use admin API"); + } + + let router = router .layer(middleware::from_fn(security_headers)) .with_state(state.clone()); - let addr: SocketAddr = format!( + let public_addr: SocketAddr = format!( "{}:{}", state.settings.server.host, state.settings.server.port ) .parse() .map_err(|e| miette::miette!("bad listen addr: {e}"))?; - tracing::info!(%addr, "listening"); + + // Start admin GraphQL server on separate port + let admin_port = state + .settings + .server + .admin_port + .unwrap_or(state.settings.server.port + 1); + let admin_addr: SocketAddr = format!("{}:{}", state.settings.server.host, admin_port) + .parse() + .map_err(|e| miette::miette!("bad admin addr: {e}"))?; + + let admin_router = crate::admin_graphql::router(seaography_schema, jobs_schema); + + // Spawn admin server in background + let admin_listener = tokio::net::TcpListener::bind(admin_addr) + .await + .into_diagnostic()?; + tracing::info!(%admin_addr, "Admin GraphQL API listening"); + tracing::info!("GraphQL Playground available at http://{}/admin/playground", admin_addr); + + tokio::spawn(async move { + axum::serve(admin_listener, admin_router) + .await + .expect("Admin server failed"); + }); + + // Start public server + tracing::info!(%public_addr, "Public API listening"); tracing::warn!("Rate limiting should be configured at the reverse proxy level for production"); - let listener = tokio::net::TcpListener::bind(addr) + let listener = tokio::net::TcpListener::bind(public_addr) .await .into_diagnostic()?; axum::serve(listener, router).await.into_diagnostic()?; @@ -1134,13 +1176,80 @@ async fn get_property( async fn set_property( State(state): State, Path((owner, key)): Path<(String, String)>, - Json(v): Json, + req: axum::http::Request, ) -> impl IntoResponse { + // Extract bearer token + let token_opt = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|s| s.to_string()); + + let token = match token_opt { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "missing_token", "error_description": "Bearer token required"})), + ) + .into_response(); + } + }; + + // Validate token and get subject + let token_row = match storage::get_access_token(&state.db, &token).await { + Ok(Some(t)) => t, + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "invalid_token", "error_description": "Invalid or expired token"})), + ) + .into_response(); + } + }; + + // Check if the authenticated user is trying to set their own property + if token_row.subject != owner { + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "forbidden", + "error_description": "You can only set your own properties" + })), + ) + .into_response(); + } + + // Extract JSON body + let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid_body", "error_description": e.to_string()})), + ) + .into_response(); + } + }; + + let v: Value = match serde_json::from_slice(&body_bytes) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid_json", "error_description": e.to_string()})), + ) + .into_response(); + } + }; + + // Set the property match storage::set_property(&state.db, &owner, &key, &v).await { Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), + Json(json!({"error": "internal_error", "error_description": e.to_string()})), ) .into_response(), } diff --git a/users.json.example b/users.json.example new file mode 100644 index 0000000..028f4af --- /dev/null +++ b/users.json.example @@ -0,0 +1,55 @@ +{ + "users": [ + { + "username": "admin", + "email": "admin@example.com", + "password": "change-me-in-production", + "enabled": true, + "email_verified": true, + "properties": { + "department": "IT", + "role": "administrator", + "display_name": "System Administrator" + } + }, + { + "username": "alice", + "email": "alice@example.com", + "password": "alice-secure-password", + "enabled": true, + "email_verified": false, + "properties": { + "department": "Engineering", + "role": "developer", + "display_name": "Alice Johnson", + "team": "Platform" + } + }, + { + "username": "bob", + "email": "bob@example.com", + "password": "bob-secure-password", + "enabled": true, + "email_verified": true, + "properties": { + "department": "Product", + "role": "product_manager", + "display_name": "Bob Smith" + } + }, + { + "username": "charlie", + "email": "charlie@example.com", + "password": "charlie-secure-password", + "enabled": false, + "email_verified": false, + "properties": { + "department": "Engineering", + "role": "developer", + "display_name": "Charlie Brown", + "team": "Backend", + "note": "Account disabled - pending onboarding" + } + } + ] +}