From ce12b997f45a893ccdf534553a8ecbaa5a029a19 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Fri, 30 Jan 2026 16:31:53 +0800 Subject: [PATCH] fix(handlers): add handlers --- Cargo.lock | 369 ++++++++++++++++++++++++++++++++-- Cargo.toml | 61 ++++-- README.md | 123 +++++++++--- docs/AUTHZ_DESIGN.md | 87 ++++++++ docs/DB_PROVISIONING.md | 90 +++++++++ docs/SCALAR_GUIDE.md | 155 ++++++++++++++ docs/TEMP.md | 23 +++ docs/TENANT_API.md | 104 ++++++++++ init.sql | 36 ---- scripts/db/rebuild_iam_db.sh | 89 ++++++++ sql/drop_iam_schema.sql | 11 + sql/schema_post_init.sql | 106 ++++++++++ sql/verify_iam_schema.sql | 74 +++++++ src/config/mod.rs | 17 +- src/db/mod.rs | 9 +- src/docs.rs | 105 ++++++++++ src/handlers/auth.rs | 67 ++++++ src/handlers/authorization.rs | 59 ++++++ src/handlers/mod.rs | 64 ++---- src/handlers/role.rs | 132 ++++++++++++ src/handlers/tenant.rs | 294 +++++++++++++++++++++++++++ src/handlers/user.rs | 293 +++++++++++++++++++++++++++ src/main.rs | 108 +++++++--- src/middleware/auth.rs | 67 ++++++ src/middleware/mod.rs | 47 ++++- src/middleware/rate_limit.rs | 369 ++++++++++++++++++++++++++++++++++ src/models.rs | 190 +++++++++++++++-- src/services/auth.rs | 222 ++++++++++++++++++++ src/services/authorization.rs | 79 ++++++++ src/services/mod.rs | 78 +------ src/services/role.rs | 59 ++++++ src/services/tenant.rs | 134 ++++++++++++ src/services/user.rs | 109 ++++++++++ src/utils/jwt.rs | 58 ++++++ src/utils/keys.rs | 40 ++++ src/utils/mod.rs | 64 +----- src/utils/password.rs | 24 +++ tests/db_smoke.rs | 47 +++++ 38 files changed, 3746 insertions(+), 317 deletions(-) create mode 100644 docs/AUTHZ_DESIGN.md create mode 100644 docs/DB_PROVISIONING.md create mode 100644 docs/SCALAR_GUIDE.md create mode 100644 docs/TEMP.md create mode 100644 docs/TENANT_API.md delete mode 100644 init.sql create mode 100755 scripts/db/rebuild_iam_db.sh create mode 100644 sql/drop_iam_schema.sql create mode 100644 sql/schema_post_init.sql create mode 100644 sql/verify_iam_schema.sql create mode 100644 src/docs.rs create mode 100644 src/handlers/auth.rs create mode 100644 src/handlers/authorization.rs create mode 100644 src/handlers/role.rs create mode 100644 src/handlers/tenant.rs create mode 100644 src/handlers/user.rs create mode 100644 src/middleware/auth.rs create mode 100644 src/middleware/rate_limit.rs create mode 100644 src/services/auth.rs create mode 100644 src/services/authorization.rs create mode 100644 src/services/role.rs create mode 100644 src/services/tenant.rs create mode 100644 src/services/user.rs create mode 100644 src/utils/jwt.rs create mode 100644 src/utils/keys.rs create mode 100644 src/utils/password.rs create mode 100644 tests/db_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 053e2f6..2b64f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "argon2" version = "0.5.3" @@ -233,7 +239,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -248,15 +256,16 @@ dependencies = [ [[package]] name = "common-telemetry" -version = "0.1.3" +version = "0.1.5" source = "sparse+https://kellnr.shay7sev.site/api/v1/crates/" -checksum = "ef5c074b9acbe91cc88b19b41869b981847032aeb06c258375ee4f65cfe8be7a" +checksum = "5fd21d86f12ac4676934836ff875b59d3ade17d90d1db84a3e2e4f6e259e149b" dependencies = [ + "anyhow", "axum", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "tracing", "tracing-appender", "tracing-subscriber", @@ -406,6 +415,20 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.10" @@ -560,12 +583,24 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -590,6 +625,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -652,6 +697,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -696,9 +747,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -715,7 +810,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -723,6 +818,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -827,6 +927,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -836,6 +937,20 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] @@ -845,33 +960,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] name = "iam-service" version = "0.1.0" dependencies = [ + "anyhow", "argon2", "async-trait", "axum", + "base64", + "chrono", "common-telemetry", "config", "dotenvy", + "governor", + "hex", "http", + "ipnet", "jsonwebtoken", "rand 0.9.2", + "rsa", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", + "tower", + "tower_governor", "tracing", "tracing-subscriber", "utoipa", @@ -1017,6 +1146,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "itoa" version = "1.0.17" @@ -1205,6 +1340,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1450,6 +1597,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1489,6 +1656,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1522,6 +1695,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.44" @@ -1596,6 +1784,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1902,7 +2099,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -1940,6 +2137,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -1992,7 +2198,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2076,7 +2282,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2115,7 +2321,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2141,7 +2347,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2211,13 +2417,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2344,6 +2570,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.11+spec-1.1.0" @@ -2375,6 +2614,35 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -2383,9 +2651,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2403,6 +2674,23 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.18", + "tonic", + "tower", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2422,7 +2710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -2490,6 +2778,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -2632,6 +2926,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2698,6 +3001,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" @@ -2708,6 +3031,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index eabd659..e67ba2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,15 +4,19 @@ version = "0.1.0" edition = "2024" [dependencies] -# 统一日志处理与错误处理 -common-telemetry = { version = "0.1.3", registry = "kellnr", default-features = false, features = [ - "error", +common-telemetry = { version = "0.1.5", registry = "kellnr", default-features = false, features = [ + "response", "telemetry", + "with-anyhow", "with-sqlx", -] } +] } # 内部公共库(统一错误/响应/日志/Telemetry) -# Web 框架 +# Web 框架与 HTTP 基础 axum = "0.8.8" +http = "1.4.0" + +# 异步运行时与通用异步工具 +async-trait = "0.1.89" tokio = { version = "1", features = ["full"] } # 序列化 @@ -21,25 +25,46 @@ serde_json = "1" # 数据库 (PostgreSQL) sqlx = { version = "0.8", features = [ - "runtime-tokio-native-tls", - "postgres", - "uuid", "chrono", + "json", + "postgres", + "runtime-tokio-native-tls", + "uuid", ] } -# 工具 -uuid = { version = "1", features = ["v4", "serde"] } -dotenvy = "0.15" # 加载 .env +# 配置管理 +config = "0.15.19" # 方便读取配置 +dotenvy = "0.15" # 加载 .env + +# 错误处理 +anyhow = "1" thiserror = "2.0.18" -jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] } -argon2 = "0.5" -rand = "0.9.2" -config = "0.15.19" # 方便读取配置 + +# 可观测性(日志与 tracing) tracing = "0.1" tracing-subscriber = "0.3" -# API 文档 (关键部分) +# 安全与加密(密码、JWT、密钥/编码) +argon2 = "0.5" +base64 = "0.22.1" +hex = "0.4.3" +jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] } +rand = "0.9.2" +rsa = "0.9.10" +uuid = { version = "1", features = ["v4", "serde"] } + +# API 文档 (OpenAPI/Scalar) utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-scalar = { version = "0.3.0", features = ["axum"] } # Scalar 集成 -async-trait = "0.1.89" -http = "1.4.0" + +# 中间件组件(限流) +governor = "0.10.4" +ipnet = "2.11.0" +tower_governor = { version = "0.8.0", features = ["axum"] } + +# 时间处理 +chrono = "0.4.43" + +[dev-dependencies] +# 测试工具 +tower = "0.5" diff --git a/README.md b/README.md index da841cb..f1c0015 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # iam-service(多租户 IAM 服务) -一个基于 Rust 的多租户身份识别与访问管理(IAM)服务雏形,当前提供“租户隔离 + 用户注册”能力,并为后续扩展登录、JWT 认证、授权(RBAC/ABAC)、租户管理等功能预留了模块边界。 +一个基于 Rust 的多租户身份识别与访问管理(IAM)服务雏形,当前已提供“租户隔离 + 用户注册/登录 + 基础租户管理 + 基于 JWT 的认证中间件 + RBAC 权限校验骨架”能力,并为后续扩展 MFA/SSO、Token 刷新/吊销、ABAC 策略引擎等功能预留了模块边界。 ## 技术栈 @@ -8,7 +8,7 @@ - Web:Axum - 数据库:PostgreSQL + SQLx - 密码:Argon2 -- Token:JWT(签发已实现;验签/认证中间件待补齐) +- Token:JWT(RS256 非对称签发/验签已实现;JWK Set 端点待补齐) - 可观测性:tracing + `common-telemetry`(私有 registry:kellnr) - API 文档:utoipa + Scalar(`/scalar`) @@ -19,8 +19,9 @@ ```mermaid flowchart LR C[Client] -->|HTTP + JSON| R[Axum Router] - R --> M[租户中间件 resolve_tenant] - M --> H[Handlers] + R --> A[认证中间件 authenticate] + A --> M[租户中间件 resolve_tenant] + M --> H[Handlers + RBAC 校验] H --> S[Services] S --> DB[(PostgreSQL)] @@ -34,7 +35,8 @@ flowchart LR - `users.tenant_id` 外键引用 `tenants.id`(强制用户必须归属某个租户) - `users(tenant_id, email)` 联合唯一索引(同租户邮箱唯一,不同租户可重复) -- HTTP 层通过请求头 `X-Tenant-ID: ` 传入租户上下文,并在 Service/SQL 查询中使用 `tenant_id` 过滤 +- HTTP 层优先通过 `Authorization: Bearer ` 中的 `tenant_id` claim 传入租户上下文,并在 Service/SQL 查询中使用 `tenant_id` 过滤 +- 兼容:仍支持请求头 `X-Tenant-ID: `,若同时提供 Header 与 Token,将强制校验两者一致,否则返回 403 ## 项目结构说明 @@ -42,11 +44,14 @@ flowchart LR - [src/config/mod.rs](file:///home/shay/project/backend/iam-service/src/config/mod.rs):环境变量配置加载 - [src/db/mod.rs](file:///home/shay/project/backend/iam-service/src/db/mod.rs):PostgreSQL 连接池初始化(迁移功能目前未启用) - [src/middleware/mod.rs](file:///home/shay/project/backend/iam-service/src/middleware/mod.rs):多租户中间件与 `TenantId` 提取器 +- [src/middleware/auth.rs](file:///home/shay/project/backend/iam-service/src/middleware/auth.rs):JWT 认证中间件与 `AuthContext` 提取器 - [src/handlers/mod.rs](file:///home/shay/project/backend/iam-service/src/handlers/mod.rs):HTTP Handler(控制器层),负责参数解析、调用 Service、返回统一响应 - [src/services/mod.rs](file:///home/shay/project/backend/iam-service/src/services/mod.rs):业务逻辑(注册/登录)与数据库交互 - [src/models.rs](file:///home/shay/project/backend/iam-service/src/models.rs):DB Model 与请求/响应 DTO(同时用于 OpenAPI Schema) - [src/utils/mod.rs](file:///home/shay/project/backend/iam-service/src/utils/mod.rs):密码哈希与 JWT 签发工具 -- [init.sql](file:///home/shay/project/backend/iam-service/init.sql):初始化数据库/表结构的 SQL(包含 tenants/users) +- [sql/schema_post_init.sql](file:///home/shay/project/backend/iam-service/sql/schema_post_init.sql):Schema 初始化(DDL+DML,适用于开发/测试) +- [scripts/db/rebuild_iam_db.sh](file:///home/shay/project/backend/iam-service/scripts/db/rebuild_iam_db.sh):一键重建 schema(可选备份+DROP+重建+校验) +- [docs/DB_PROVISIONING.md](file:///home/shay/project/backend/iam-service/docs/DB_PROVISIONING.md):数据库/用户创建与 schema 初始化说明(归档) ## 快速开始 @@ -64,10 +69,11 @@ git clone cd iam-service ``` -2. 初始化数据库(示例) +2. 初始化数据库(开发/测试示例) ```bash -psql -h -U -f init.sql +export DATABASE_URL='postgres://iam_service_user:***@:5432/iam_service_db' +BACKUP=1 ./scripts/db/rebuild_iam_db.sh ``` 3. 配置环境变量 @@ -79,7 +85,7 @@ cp .env.example .env 按需修改 `.env`: - `DATABASE_URL`:PostgreSQL 连接串 -- `JWT_SECRET`:JWT 签名密钥(务必替换为强随机字符串) +- `JWT_SECRET`:保留字段(当前 RS256 实现未使用;后续将用于密钥加载/加密存储) - `PORT`:监听端口 4. 启动服务 @@ -95,6 +101,32 @@ cargo run ## 核心功能与 API +### 租户注册 + +`POST /tenants/register` + +```bash +curl -X POST "http://127.0.0.1:3000/tenants/register" \ + -H "Content-Type: application/json" \ + -d '{"name":"Tenant A","config":{"theme":{"primary":"#1d4ed8"}}}' +``` + +返回(示例): + +```json +{ + "code": 0, + "message": "Created", + "data": { + "id": "22222222-2222-2222-2222-222222222222", + "name": "Tenant A", + "status": "active", + "config": { "theme": { "primary": "#1d4ed8" } } + }, + "trace_id": null +} +``` + ### 用户注册 `POST /auth/register`(需要请求头 `X-Tenant-ID`) @@ -110,35 +142,72 @@ curl -X POST "http://127.0.0.1:3000/auth/register" \ ```json { - "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "user@example.com" + "code": 0, + "message": "Created", + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com" + }, + "trace_id": null } ``` -### 登录与鉴权(待完善) +### 登录 -代码中已存在 `AuthService::login()` 与 JWT 签发工具,但目前没有对外暴露的 HTTP 路由,也未实现 JWT 验签/认证中间件与权限控制。 +`POST /auth/login`(需要请求头 `X-Tenant-ID`) -## 多租户使用指南 - -### 创建租户 - -当前无“租户管理 API”,建议通过 SQL 初始化租户: - -```sql -INSERT INTO tenants (id, name) VALUES ('22222222-2222-2222-2222-222222222222', 'Tenant A'); +```bash +curl -X POST "http://127.0.0.1:3000/auth/login" \ + -H "Content-Type: application/json" \ + -H "X-Tenant-ID: 11111111-1111-1111-1111-111111111111" \ + -d '{"email":"user@example.com","password":"securePassword123"}' ``` -### 访问租户资源 +返回(示例): -所有请求都需要携带租户头: +```json +{ + "code": 0, + "message": "Success", + "data": { + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 900 + }, + "trace_id": null +} +``` -- `X-Tenant-ID: ` +### 租户信息维护(需要鉴权 + 权限) -建议在后续迭代中: +- `GET /tenants/me`(需要 `tenant:read`) +- `PATCH /tenants/me`(需要 `tenant:write`) +- `POST /tenants/me/status`(需要 `tenant:write`) +- `DELETE /tenants/me`(需要 `tenant:write`) -- 将 `tenant_id` 与用户身份绑定(JWT claims / session) -- 校验请求头租户与 token 内租户一致,避免伪造跨租户访问 +调用示例: + +```bash +curl -X GET "http://127.0.0.1:3000/tenants/me" \ + -H "Authorization: Bearer " +``` + +## 权限控制(当前实现方式) + +- 认证(Authentication):在中间件层完成([auth.rs](file:///home/shay/project/backend/iam-service/src/middleware/auth.rs)),统一解析 `Authorization`,验签 JWT,并注入 `AuthContext`。 +- 租户上下文(Tenant Context):在中间件层完成([mod.rs](file:///home/shay/project/backend/iam-service/src/middleware/mod.rs)),优先使用 `AuthContext.tenant_id`,并兼容 `X-Tenant-ID`(强制一致性校验)。 +- 授权(Authorization / RBAC):在 Handler 层作为请求级拦截点调用 Service([authorization.rs](file:///home/shay/project/backend/iam-service/src/services/authorization.rs)),把“查库/规则”放在 Service 层,避免 Handler 直接拼 SQL。 + +更完整的“RBAC + ABAC 混合模型”落地建议见 [AUTHZ_DESIGN.md](file:///home/shay/project/backend/iam-service/AUTHZ_DESIGN.md)。 + +## 限流(生产建议) + +- 登录/注册已在路由级挂载限流(见 [rate_limit.rs](file:///home/shay/project/backend/iam-service/src/middleware/rate_limit.rs))。 +- 生产推荐在网关/边缘层做全局限流与 Bot 防护,服务内限流作为兜底。 +- 可信代理模式: + - 配置 `TRUSTED_PROXY_CIDRS`,仅当对端连接 IP 命中这些网段时,才信任 `Forwarded` / `X-Forwarded-For` / `X-Real-IP` 提取真实客户端 IP 进行限流。 + - 未命中可信代理时,将忽略这些 Header,退回按对端连接 IP 限流,避免 Header 伪造绕过。 ## 部署指南 diff --git a/docs/AUTHZ_DESIGN.md b/docs/AUTHZ_DESIGN.md new file mode 100644 index 0000000..f8f1e05 --- /dev/null +++ b/docs/AUTHZ_DESIGN.md @@ -0,0 +1,87 @@ +# 权限控制架构设计(Tenant-User-Role) + +## 目标 + +- 保证租户隔离:任何用户只能访问其 Token 绑定的租户数据 +- 保证授权一致:同一类请求在所有入口都遵循同样的鉴权与授权规则 +- 保证可演进:RBAC 可以稳定运行,ABAC 可逐步引入策略引擎与审计闭环 + +## 分层职责与落点 + +### 认证(Authentication):中间件层 + +- 位置:`src/middleware/auth.rs` +- 输入:`Authorization: Bearer ` +- 输出:解析/验签 Token 并注入 `AuthContext { tenant_id, user_id, roles, permissions }` +- 选择理由: + - 认证属于横切关注点,放在中间件可避免每个 Handler 重复实现 + - 401/403 与错误码可统一,便于网关与客户端处理 + - 便于埋点:在统一入口记录审计字段(IP/UA/结果/耗时) + +### 租户上下文(Tenant Context):中间件层 + +- 位置:`src/middleware/mod.rs` +- 行为: + - 优先使用 `AuthContext.tenant_id` 注入 `TenantId` + - 兼容 `X-Tenant-ID`,若 Header 与 Token 同时存在则强制一致,否则返回 403 +- 选择理由: + - 租户隔离是安全边界,必须在最靠前的入口稳定执行,避免业务分支遗漏 + +### 授权(Authorization):Service 层为规则源,Handler/中间件为拦截点 + +当前实现采用“规则在 Service、拦截在 Handler”的折中: + +- 规则源(Service):`src/services/authorization.rs` + - 负责 RBAC 查库:用户→角色→权限 + - 产出统一决策(允许/拒绝)并返回 `AppError::PermissionDenied` +- 拦截点(Handler):在具体接口中调用 `AuthorizationService::require_permission(...)` + - 选择理由: + - Handler 更接近“路由-动作”语义,决定某个接口需要什么权限更直观 + - Service 只负责“如何判断”,不关心“哪个路由需要什么” + +当路由数量增长后,建议演进为“拦截点上移到中间件/Layer”: + +- 给不同 Router 分组挂载不同的授权 Layer(如 tenant 管理、用户管理、角色管理) +- 或引入宏/属性(例如 `#[require("tenant:write")]`)生成统一的 Guard + +### ABAC:建议落点(后续实现) + +ABAC 需要资源属性、环境属性、请求属性,通常依赖: + +- 资源属性:DB 查询或缓存读取(属于业务领域) +- 环境属性:IP、时间、设备风险、地理位置(属于安全/风控) +- 请求属性:请求参数与上下文字段 + +因此 ABAC 的“策略评估”建议由独立模块/服务承担(例如 `PolicyEngine`),并由 Handler 在调用业务 Service 之前完成决策;决策审计由统一审计组件记录。 + +## 审计与可观测性 + +- 建议在认证/授权路径内记录审计事件: + - actor:tenant_id、user_id + - decision:allow/deny + - policy:匹配的 RBAC 权限或 ABAC 策略 ID + - cost:授权耗时 +- 当前库 `common-telemetry` 已提供统一错误返回;建议后续在错误响应中补齐 trace_id(从 tracing context 获取)。 + +## 数据隔离方案对比(建议) + +### 现状:共享库共享表(tenant_id 过滤) + +- 优点:实现简单、性能可控、易于水平扩容 +- 风险:任何遗漏 `tenant_id` 条件都可能造成越权 +- 现有防线: + - 中间件注入 `TenantId` + - Service 查询必须显式绑定 tenant_id + +### 演进方案 A:独立 Schema(每租户一个 schema) + +- 优点:逻辑隔离更强,可按租户迁移/清理 +- 缺点:管理成本高,连接池与迁移复杂 + +### 演进方案 B:Postgres Row-Level Security(RLS) + +- 优点:隔离在数据库层兜底,减少“遗漏 where tenant_id”的风险 +- 缺点:策略配置复杂,需要设置 session 变量(如 `app.tenant_id`)并在连接池层保证一致 + +建议:当租户数量增长或合规要求上升时,引入 RLS 作为数据库兜底隔离层。 + diff --git a/docs/DB_PROVISIONING.md b/docs/DB_PROVISIONING.md new file mode 100644 index 0000000..398df63 --- /dev/null +++ b/docs/DB_PROVISIONING.md @@ -0,0 +1,90 @@ +# 数据库初始化与权限(归档) + +本项目的 schema 初始化已从历史的 `init.sql` 拆分为“基础设施初始化(DB/User)”与“schema 初始化(DDL/DML)”两部分: + +- **基础设施初始化(DB/User)**:通常由 DBA/平台完成,适用于首次部署或新环境准备。 +- **schema 初始化(DDL/DML)**:适用于开发/测试环境的可重复重建,见 `sql/schema_post_init.sql`,并由 `scripts/db/rebuild_iam_db.sh` 一键执行。 + +## 1) 创建用户与数据库(首次环境准备) + +在具有足够权限的数据库账号下执行: + +```sql +CREATE USER iam_service_user WITH PASSWORD 'iam_service_password'; +CREATE DATABASE iam_service_db OWNER iam_service_user; +GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user; +``` + +注意事项: + +- 生产环境不要在仓库内硬编码密码;应由密钥管理系统注入并轮换。 +- 如果需要启用扩展(如 `uuid-ossp`),请确认应用用户是否有权限,或由 DBA 预先安装。 + +## 2) schema 重建(开发/CI) + +推荐使用一键脚本(会 DROP 并重建表结构): + +```bash +export DATABASE_URL='postgres://iam_service_user:***@host:5432/iam_service_db' +BACKUP=1 ./scripts/db/rebuild_iam_db.sh +``` + +脚本会按顺序执行: + +- `sql/drop_iam_schema.sql` +- `sql/schema_post_init.sql` +- `sql/verify_iam_schema.sql` + +## 3) 常见问题 + +### 3.1 `.env` 配了 DATABASE_URL 但脚本报 “DATABASE_URL is required” + +`.env` 只是“文件”,不会自动变成进程环境变量。`cargo run` 会通过 `dotenvy` 加载 `.env`,但 bash 脚本默认不会。 + +解决方式: + +- 直接 `export DATABASE_URL=...` 后再执行脚本;或 +- 保持 `.env` 存在于项目根目录,脚本会自动读取其中的 `DATABASE_URL`。 + +### 3.2 执行脚本报 “psql: 未找到命令” + +原因:系统未安装 PostgreSQL 客户端工具(`psql`/`pg_dump`),或不在 `PATH` 中。 + +安装方式: + +- Ubuntu/Debian: + +```bash +sudo apt-get update && sudo apt-get install -y postgresql-client +``` + +- RHEL/CentOS/Fedora: + +```bash +sudo dnf install -y postgresql +``` + +- Alpine: + +```bash +sudo apk add postgresql-client +``` + +- macOS(Homebrew): + +```bash +brew install libpq +brew link --force libpq +``` + +验证方式: + +```bash +psql --version +``` + +安装完成后重新执行: + +```bash +BACKUP=1 ./scripts/db/rebuild_iam_db.sh +``` diff --git a/docs/SCALAR_GUIDE.md b/docs/SCALAR_GUIDE.md new file mode 100644 index 0000000..69d0969 --- /dev/null +++ b/docs/SCALAR_GUIDE.md @@ -0,0 +1,155 @@ +# IAM Service — Scalar 调用顺序指南 + +## Authentication(认证方式) + +本服务使用 **JWT Bearer Token**: + +- 登录成功后拿到 `access_token` +- 后续请求在 Header 中带: + - `Authorization: Bearer ` +- 租户上下文: + - 保护接口默认从 Token claim 的 `tenant_id` 推导租户 + - 可选兼容 `X-Tenant-ID: `,若同时提供 Header 与 Token,则必须一致,否则返回 403 + +## 通用响应结构 + +成功响应: + +```json +{ "code": 0, "message": "Success|Created|Accepted", "data": {}, "trace_id": null } +``` + +错误响应(示例): + +```json +{ "code": 20006, "message": "Missing authorization header", "details": null, "trace_id": null } +``` + +常见错误码(节选): + +- 20006:缺少必要 Header(如 Authorization) +- 20003:无权限(403) +- 20005:账号或密码错误 +- 30000:请求参数错误(400) +- 30002:资源不存在(404) +- 30003:资源冲突(409) +- 40000:请求过于频繁(429) +- 10001:数据库错误(500) + +## Step-by-step(可复制流程) + +### Step 0:创建租户(可选) + +**POST** `/tenants/register` + +- Header:无 +- Body: + +```json +{ "name": "Tenant A", "config": { "theme": { "primary": "#1d4ed8" } } } +``` + +成功(201)从 `data.id` 取出租户 ID: + +```json +{ "code": 0, "message": "Created", "data": { "id": "", "name": "Tenant A", "status": "active", "config": {} }, "trace_id": null } +``` + +下一步依赖:`tenant_id`(用于注册/登录时的 `X-Tenant-ID`)。 + +### Step 1:注册用户 + +**POST** `/auth/register` + +- 必需 Header:`X-Tenant-ID: ` +- Body: + +```json +{ "email": "user@example.com", "password": "securePassword123" } +``` + +成功(201)从 `data.id` 取出 `user_id`(后续可用于用户管理接口): + +```json +{ "code": 0, "message": "Created", "data": { "id": "", "email": "user@example.com" }, "trace_id": null } +``` + +### Step 2:登录获取访问令牌(Authentication 入口) + +**POST** `/auth/login` + +- 必需 Header:`X-Tenant-ID: ` +- Body: + +```json +{ "email": "user@example.com", "password": "securePassword123" } +``` + +成功(200)从 `data.access_token` 取出访问令牌: + +```json +{ "code": 0, "message": "Success", "data": { "access_token": "", "refresh_token": "", "token_type": "Bearer", "expires_in": 900 }, "trace_id": null } +``` + +下一步依赖:`access_token`。 + +### Step 3:获取当前租户信息(Tenant) + +**GET** `/tenants/me` + +- 必需 Header:`Authorization: Bearer ` +- 可选 Header:`X-Tenant-ID: `(如提供必须与 token tenant_id 一致) + +成功(200): + +```json +{ "code": 0, "message": "Success", "data": { "id": "", "name": "Tenant A", "status": "active", "config": {} }, "trace_id": null } +``` + +### Step 4:查看当前用户权限(Me) + +**GET** `/me/permissions` + +- 必需 Header:`Authorization: Bearer ` + +成功(200): + +```json +{ "code": 0, "message": "Success", "data": ["tenant:read","tenant:write"], "trace_id": null } +``` + +下一步依赖:确认具备目标权限(例如 `user:read` / `role:read`)。 + +### Step 5:列出用户(User) + +**GET** `/users?page=1&page_size=20` + +- 必需 Header:`Authorization: Bearer ` +- 分页规则: + - `page` 默认 1,必须 >= 1 + - `page_size` 默认 20,范围 1..=200 + +成功(200): + +```json +{ "code": 0, "message": "Success", "data": [{ "id": "", "email": "user@example.com" }], "trace_id": null } +``` + +### Step 6:列出角色(Role) + +**GET** `/roles` + +- 必需 Header:`Authorization: Bearer ` + +成功(200): + +```json +{ "code": 0, "message": "Success", "data": [{ "id": "", "name": "Admin", "description": "..." }], "trace_id": null } +``` + +## 限流说明(Auth) + +- `/auth/login`:约 2 req/s,burst 10(同一 IP) +- `/auth/register`:约 1 req/s,burst 5(同一 IP) +- 触发后返回:HTTP 429 + `code=40000` + diff --git a/docs/TEMP.md b/docs/TEMP.md new file mode 100644 index 0000000..bdd132f --- /dev/null +++ b/docs/TEMP.md @@ -0,0 +1,23 @@ +```json +{ + "code": 0, + "message": "Success", + "data": { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk3NTc3MTAsImlhdCI6MTc2OTc1NjgxMCwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl19.VGsoZdMwodRWKW4NQuQwezh3xZivFbRUzSw_-RnD-EJIv7qPHmcNbdIcxNSKXCHGKdK_b1B3404m7ji2wdEOweKz0GEcwPWswc9fannP5_6l9k83jn0ZKQ1pS3l27V5mr9feym_83ZIqEtFfKcCKGIM684Ze7CMM6i-gfYisn0poG1XW3K4ptsVnuNZux0TWNFl5TO6kgiw0_399tZnSH5qc4CckHOuoF3Jz1Q2aIgnvyfxbxEFTNZm-ykjhlbK5zWBpYfJdYOALQg-FQ3eGuVnSF4U_If1MNQKQ0p6DqDKMCO0IfdCr2WMBvfCYA1SxmPbETr2Tm7RguhJBEiVQ4Q", + "refresh_token": "e1649e730ef3583cd80087f7fa63774330deb88e81aec2edae41322764e441eb", + "token_type": "Bearer", + "expires_in": 900 + } +} + +{ + "code": 0, + "message": "Success", + "data": { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmOTg5MWI2Yy0xNTQ1LTQ0NGItODIxOC0yNTQ5MDA1NDczMGYiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk3NTg4NDMsImlhdCI6MTc2OTc1Nzk0MywiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6W10sInBlcm1pc3Npb25zIjpbXX0.Is-JjQ9l1BuZoYVr6QAt-4ZSfRQro3COPSVYHVl1NI0CAz7T2x9Hz2QiJPFamjsX7cFrCIJxNMn9ioqK2FzEnSTu2oVATqlhE5OMCcK1M7Mq_FvZ7WqGgPl8CE06s7yvneC97mknk5y1-nm5claYYGeHAjLrRPbjO2t3zUQO5boNPdjzEGx4kTFvgmJbwWMrsBtkeaW1nacxhFiSj-RFCSzHOOaSRoKLDsx9nUsuDJL1NCaHDuKDacphkwpjP5AWLd41hlrs6PC8XLUPey2EXHqJ5SmOaDdQ60LfItvohgHBTY6CO8IUIJgtZobrFsKUlnHqA9eZwm2dvAW560g4VA", + "refresh_token": "71e3ba6285b503891294ca7dad81cdc6bd5b3f72b09b1e2b796979a433d687f3", + "token_type": "Bearer", + "expires_in": 900 + } +} +``` \ No newline at end of file diff --git a/docs/TENANT_API.md b/docs/TENANT_API.md new file mode 100644 index 0000000..dc00788 --- /dev/null +++ b/docs/TENANT_API.md @@ -0,0 +1,104 @@ +# 租户管理 API 使用说明 + +## 通用约定 + +### 成功响应 + +所有成功响应使用 `common-telemetry` 的统一包装: + +```json +{ + "code": 0, + "message": "Success|Created|Accepted", + "data": {}, + "trace_id": null +} +``` + +### 错误响应 + +错误响应由 `AppError` 统一转换为 JSON: + +```json +{ + "code": 30000, + "message": "Invalid request parameters: ...", + "details": "...", + "trace_id": null +} +``` + +常见错误码(节选,来自 `common-telemetry`): + +- 20006:缺少认证 Header(`Authorization`) +- 20003:无权限(403) +- 30000:请求参数错误(400) +- 30002:资源不存在(404) +- 10001:数据库错误(500) + +## 认证与租户上下文 + +- 受保护接口需要: + - `Authorization: Bearer ` +- 租户上下文优先来自 Token 的 `tenant_id` claim +- 兼容 `X-Tenant-ID`: + - 如果同时传 `X-Tenant-ID` 与 Token,则必须一致,否则返回 403 + +## 接口列表 + +### 1) 租户注册(公共) + +`POST /tenants/register` + +请求体: + +```json +{ + "name": "Tenant A", + "config": { + "theme": { "primary": "#1d4ed8" }, + "password_policy": { "min_len": 12 } + } +} +``` + +响应:`TenantResponse` + +### 2) 获取当前租户信息(需要 tenant:read) + +`GET /tenants/me` + +请求头: + +- `Authorization: Bearer ` +- `X-Tenant-ID: `(可选) + +### 3) 更新当前租户信息(需要 tenant:write) + +`PATCH /tenants/me` + +请求体: + +```json +{ + "name": "New Tenant Name", + "config": { "theme": { "primary": "#0ea5e9" } } +} +``` + +### 4) 变更当前租户状态(需要 tenant:write) + +`POST /tenants/me/status` + +请求体: + +```json +{ "status": "active|disabled|suspended" } +``` + +### 5) 删除当前租户(需要 tenant:write) + +`DELETE /tenants/me` + +说明:当前实现为物理删除。生产建议改为软删除并触发异步数据清理流程。 + diff --git a/init.sql b/init.sql deleted file mode 100644 index 7f3de97..0000000 --- a/init.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 1. 创建 iam_service 专用用户 -CREATE USER iam_service_user WITH PASSWORD 'iam_service_password'; - --- 2. 创建 iam_service 专用数据库 -CREATE DATABASE iam_service_db OWNER iam_service_user; - --- 3. 赋予权限(确保它能在 iam_service_db 库里创建 Schema) -GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user; - --- 进入 iam_service_db - --- 1. 启用 UUID 扩展 -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- 2. 租户表 -CREATE TABLE tenants ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(255) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- 3. 用户表 (多租户核心) -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id), - email VARCHAR(255) NOT NULL, - password_hash VARCHAR(255) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- 4. 关键:创建联合唯一索引 --- 允许不同租户拥有相同的 email,但同一租户内 email 必须唯一 -CREATE UNIQUE INDEX idx_users_tenant_email ON users(tenant_id, email); - --- 5. 初始化一个测试租户 (方便后续测试) -INSERT INTO tenants (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'Default Corp'); \ No newline at end of file diff --git a/scripts/db/rebuild_iam_db.sh b/scripts/db/rebuild_iam_db.sh new file mode 100755 index 0000000..8f76be8 --- /dev/null +++ b/scripts/db/rebuild_iam_db.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +load_database_url_from_env_file() { + local env_file="$1" + local line value + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + line="${line#export }" + if [[ "${line}" == DATABASE_URL=* ]]; then + value="${line#DATABASE_URL=}" + value="${value%$'\r'}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "${value}" + return 0 + fi + done < "${env_file}" + return 1 +} + +DATABASE_URL="${DATABASE_URL:-}" +if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then + DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)" +fi +if [[ -z "${DATABASE_URL}" ]]; then + echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)" + exit 1 +fi + +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found in PATH" + if [[ -r /etc/os-release ]]; then + . /etc/os-release + case "${ID:-}" in + ubuntu|debian) + echo "Install (Ubuntu/Debian): sudo apt-get update && sudo apt-get install -y postgresql-client" + ;; + centos|rhel|fedora) + echo "Install (RHEL/CentOS/Fedora): sudo dnf install -y postgresql" + ;; + alpine) + echo "Install (Alpine): sudo apk add postgresql-client" + ;; + *) + echo "Install PostgreSQL client tools (psql) for your OS and retry" + ;; + esac + else + echo "Install PostgreSQL client tools (psql) for your OS and retry" + fi + exit 127 +fi + +CHECK_ONLY="${CHECK_ONLY:-0}" +if [[ "${CHECK_ONLY}" == "1" ]]; then + echo "DATABASE_URL=${DATABASE_URL}" + exit 0 +fi + +BACKUP="${BACKUP:-0}" +BACKUP_DIR="${BACKUP_DIR:-${ROOT_DIR}/backups}" +if [[ "${BACKUP_DIR}" != /* ]]; then + BACKUP_DIR="${ROOT_DIR}/${BACKUP_DIR}" +fi + +if [[ "${BACKUP}" == "1" ]]; then + if ! command -v pg_dump >/dev/null 2>&1; then + echo "pg_dump not found; install postgres client or set BACKUP=0" + exit 1 + fi + mkdir -p "${BACKUP_DIR}" + ts="$(date +%Y%m%d_%H%M%S)" + backup_file="${BACKUP_DIR}/iam_service_db_${ts}.dump" + pg_dump "${DATABASE_URL}" -Fc -f "${backup_file}" + echo "Backup written: ${backup_file}" +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/drop_iam_schema.sql" +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/schema_post_init.sql" +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/verify_iam_schema.sql" + +echo "Rebuild completed" diff --git a/sql/drop_iam_schema.sql b/sql/drop_iam_schema.sql new file mode 100644 index 0000000..aa47b51 --- /dev/null +++ b/sql/drop_iam_schema.sql @@ -0,0 +1,11 @@ +BEGIN; +DROP TABLE IF EXISTS role_permissions; +DROP TABLE IF EXISTS user_roles; +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS roles; +DROP TABLE IF EXISTS permissions; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS tenants; +COMMIT; + diff --git a/sql/schema_post_init.sql b/sql/schema_post_init.sql new file mode 100644 index 0000000..40f18c5 --- /dev/null +++ b/sql/schema_post_init.sql @@ -0,0 +1,106 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + status VARCHAR(50) DEFAULT 'active', + config JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + phone_number VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + is_verified BOOLEAN DEFAULT FALSE, + mfa_enabled BOOLEAN DEFAULT FALSE, + mfa_secret VARCHAR(255), + last_login_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_users_tenant_email ON users(tenant_id, email); + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + name VARCHAR(50) NOT NULL, + description TEXT, + is_system BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(tenant_id, name) +); + +CREATE TABLE permissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + resource VARCHAR(50), + action VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE role_permissions ( + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (user_id, role_id) +); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + is_revoked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + replaced_by_token_hash VARCHAR(255) +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id), + user_id UUID, + action VARCHAR(100) NOT NULL, + resource VARCHAR(100), + ip_address VARCHAR(45), + user_agent TEXT, + status VARCHAR(20) NOT NULL, + details JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +INSERT INTO tenants (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'Default Corp'); + +INSERT INTO permissions (code, description, resource, action) VALUES +('user:read', 'View users', 'user', 'read'), +('user:write', 'Create/Update/Delete users', 'user', 'write'), +('role:read', 'View roles', 'role', 'read'), +('role:write', 'Manage roles', 'role', 'write'), +('tenant:read', 'View tenant info', 'tenant', 'read'), +('tenant:write', 'Manage tenant settings and status', 'tenant', 'write'); + +INSERT INTO roles (tenant_id, name, description, is_system) VALUES +('11111111-1111-1111-1111-111111111111', 'Admin', 'Administrator with full access', TRUE); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.name = 'Admin' AND r.tenant_id = '11111111-1111-1111-1111-111111111111'; + diff --git a/sql/verify_iam_schema.sql b/sql/verify_iam_schema.sql new file mode 100644 index 0000000..9bb2cc1 --- /dev/null +++ b/sql/verify_iam_schema.sql @@ -0,0 +1,74 @@ +DO $$ +BEGIN + IF to_regclass('public.tenants') IS NULL THEN + RAISE EXCEPTION 'missing table: tenants'; + END IF; + IF to_regclass('public.users') IS NULL THEN + RAISE EXCEPTION 'missing table: users'; + END IF; + IF to_regclass('public.roles') IS NULL THEN + RAISE EXCEPTION 'missing table: roles'; + END IF; + IF to_regclass('public.permissions') IS NULL THEN + RAISE EXCEPTION 'missing table: permissions'; + END IF; + IF to_regclass('public.user_roles') IS NULL THEN + RAISE EXCEPTION 'missing table: user_roles'; + END IF; + IF to_regclass('public.role_permissions') IS NULL THEN + RAISE EXCEPTION 'missing table: role_permissions'; + END IF; + IF to_regclass('public.refresh_tokens') IS NULL THEN + RAISE EXCEPTION 'missing table: refresh_tokens'; + END IF; + IF to_regclass('public.audit_logs') IS NULL THEN + RAISE EXCEPTION 'missing table: audit_logs'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'tenants' AND column_name = 'status' + ) THEN + RAISE EXCEPTION 'tenants.status missing'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'tenants' AND column_name = 'config' + ) THEN + RAISE EXCEPTION 'tenants.config missing'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'mfa_enabled' + ) THEN + RAISE EXCEPTION 'users.mfa_enabled missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'users' AND indexname = 'idx_users_tenant_email' + ) THEN + RAISE EXCEPTION 'missing index: idx_users_tenant_email'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'users' AND c.contype = 'f' AND pg_get_constraintdef(c.oid) LIKE 'FOREIGN KEY (tenant_id)%' + ) THEN + RAISE EXCEPTION 'missing foreign key users.tenant_id -> tenants.id'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM tenants WHERE id = '11111111-1111-1111-1111-111111111111' + ) THEN + RAISE EXCEPTION 'missing seed tenant Default Corp'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'user:read') THEN + RAISE EXCEPTION 'missing seed permission user:read'; + END IF; +END $$; + diff --git a/src/config/mod.rs b/src/config/mod.rs index 56bc59b..1b1d3d2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,13 +8,15 @@ pub struct AppConfig { pub log_dir: String, pub log_file_name: String, pub database_url: String, + pub db_max_connections: u32, + pub db_min_connections: u32, pub jwt_secret: String, pub port: u16, } impl AppConfig { - pub fn from_env() -> Self { - Self { + pub fn from_env() -> Result { + Ok(Self { service_name: env::var("SERVICE_NAME").unwrap_or_else(|_| "iam-service".into()), log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()), log_to_file: env::var("LOG_TO_FILE") @@ -22,13 +24,14 @@ impl AppConfig { .unwrap_or(false), log_dir: env::var("LOG_DIR").unwrap_or_else(|_| "./log".into()), log_file_name: env::var("LOG_FILE_NAME").unwrap_or_else(|_| "iam.log".into()), - database_url: env::var("DATABASE_URL").expect("DATABASE_URL required"), - jwt_secret: env::var("JWT_SECRET") - .expect("JWT_SECRET required, generate by run 'openssl rand -base64 32'"), + database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL environment variable is required")?, + db_max_connections: env::var("DB_MAX_CONNECTIONS").unwrap_or("20".into()).parse().map_err(|_| "DB_MAX_CONNECTIONS must be a number")?, + db_min_connections: env::var("DB_MIN_CONNECTIONS").unwrap_or("5".into()).parse().map_err(|_| "DB_MIN_CONNECTIONS must be a number")?, + jwt_secret: env::var("JWT_SECRET").map_err(|_| "JWT_SECRET environment variable is required")?, port: env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() - .unwrap(), - } + .map_err(|_| "PORT must be a valid number")?, + }) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 1c091e7..705999c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,13 +1,14 @@ +use crate::config::AppConfig; use sqlx::postgres::{PgPool, PgPoolOptions}; use std::time::Duration; /// 初始化数据库连接池 -pub async fn init_pool(database_url: &str) -> Result { +pub async fn init_pool(config: &AppConfig) -> Result { PgPoolOptions::new() - .max_connections(20) // 根据服务器规格调整,IAM服务通常并发高 - .min_connections(5) + .max_connections(config.db_max_connections) + .min_connections(config.db_min_connections) .acquire_timeout(Duration::from_secs(3)) // 获取连接超时时间 - .connect(database_url) + .connect(&config.database_url) .await } diff --git a/src/docs.rs b/src/docs.rs new file mode 100644 index 0000000..1598f28 --- /dev/null +++ b/src/docs.rs @@ -0,0 +1,105 @@ +use crate::handlers; +use crate::models::{ + CreateRoleRequest, CreateTenantRequest, CreateUserRequest, LoginRequest, LoginResponse, Role, + RoleResponse, Tenant, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest, + UpdateUserRequest, User, UserResponse, +}; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::{Modify, OpenApi}; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi + .components + .get_or_insert_with(utoipa::openapi::Components::new); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ); + } +} + +#[derive(OpenApi)] +#[openapi( + modifiers(&SecurityAddon), + info( + title = "IAM Service API", + version = "0.1.0", + description = include_str!("../docs/SCALAR_GUIDE.md") + ), + paths( + handlers::auth::register_handler, + handlers::auth::login_handler, + handlers::authorization::my_permissions_handler, + handlers::tenant::create_tenant_handler, + handlers::tenant::get_tenant_handler, + handlers::tenant::update_tenant_handler, + handlers::tenant::update_tenant_status_handler, + handlers::tenant::delete_tenant_handler, + handlers::role::create_role_handler, + handlers::role::list_roles_handler, + handlers::user::list_users_handler, + handlers::user::get_user_handler, + handlers::user::update_user_handler, + handlers::user::delete_user_handler, + // Add other handlers here as you implement them + ), + components( + schemas( + User, + UserResponse, + CreateUserRequest, + UpdateUserRequest, + LoginRequest, + LoginResponse, + Role, + CreateRoleRequest, + RoleResponse, + Tenant, + TenantResponse, + CreateTenantRequest, + UpdateTenantRequest, + UpdateTenantStatusRequest + ) + ), + tags( + (name = "Auth", description = "认证:注册/登录/令牌"), + (name = "Tenant", description = "租户:创建/查询/更新/状态/删除"), + (name = "User", description = "用户:查询/列表/更新/删除(需权限)"), + (name = "Role", description = "角色:创建/列表(需权限)"), + (name = "Me", description = "当前用户:权限自查等"), + (name = "Policy", description = "策略:预留(ABAC/策略引擎后续扩展)") + ) +)] +pub struct ApiDoc; + +#[cfg(test)] +mod tests { + use super::ApiDoc; + use utoipa::OpenApi; + + #[test] + fn openapi_schema_contains_defaults() { + let doc = ApiDoc::openapi(); + let json = serde_json::to_value(&doc).unwrap(); + + let token_type_default = json + .pointer("/components/schemas/LoginResponse/properties/token_type/default") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!(token_type_default, "Bearer"); + + let tenant_status_default = json + .pointer("/components/schemas/Tenant/properties/status/default") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!(tenant_status_default, "active"); + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 0000000..17b745d --- /dev/null +++ b/src/handlers/auth.rs @@ -0,0 +1,67 @@ +use crate::handlers::AppState; +use crate::middleware::TenantId; +use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, UserResponse}; +use axum::{Json, extract::State}; +use common_telemetry::{AppError, AppResponse}; +use tracing::instrument; + +/// 注册接口 +#[utoipa::path( + post, + path = "/auth/register", + tag = "Auth", + request_body = CreateUserRequest, + responses( + (status = 201, description = "User created", body = UserResponse), + (status = 400, description = "Bad request"), + (status = 429, description = "Too many requests") + ), + params( + ("X-Tenant-ID" = String, Header, description = "Tenant UUID") + ) +)] +#[instrument(skip(state, payload))] +pub async fn register_handler( + // 1. 自动注入 TenantId (由中间件解析) + TenantId(tenant_id): TenantId, + // 2. 获取全局状态中的 Service + State(state): State, + // 3. 获取 Body + Json(payload): Json, +) -> Result, AppError> { + let user = state.auth_service.register(tenant_id, payload).await?; + + // 转换为 Response DTO (隐藏密码等敏感信息) + let response = UserResponse { + id: user.id, + email: user.email.clone(), + }; + + Ok(AppResponse::created(response)) +} + +/// 登录接口 +#[utoipa::path( + post, + path = "/auth/login", + tag = "Auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = LoginResponse), + (status = 401, description = "Unauthorized"), + (status = 429, description = "Too many requests") + ), + params( + ("X-Tenant-ID" = String, Header, description = "Tenant UUID") + ) +)] +#[instrument(skip(state, payload))] +pub async fn login_handler( + TenantId(tenant_id): TenantId, + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let response = state.auth_service.login(tenant_id, payload).await?; + + Ok(AppResponse::ok(response)) +} diff --git a/src/handlers/authorization.rs b/src/handlers/authorization.rs new file mode 100644 index 0000000..87399e6 --- /dev/null +++ b/src/handlers/authorization.rs @@ -0,0 +1,59 @@ +use crate::handlers::AppState; +use crate::middleware::TenantId; +use crate::middleware::auth::AuthContext; +use axum::extract::State; +use common_telemetry::{AppError, AppResponse}; +use tracing::instrument; + +#[utoipa::path( + get, + path = "/me/permissions", + tag = "Me", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "当前用户权限列表", body = [String]), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state))] +/// 查询当前登录用户在当前租户下的权限编码列表。 +/// +/// 用途: +/// - 快速自查当前令牌是否携带期望的权限(便于联调与排障)。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// - Header `X-Tenant-ID`(可选;若提供需与 Token 中 tenant_id 一致,否则返回 403) +/// +/// 输出: +/// - `200`:权限字符串数组(如 `user:read`) +/// +/// 异常: +/// - `401`:未携带或无法解析访问令牌 +/// - `403`:租户不匹配或无权访问 +pub async fn my_permissions_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, +) -> Result>, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + + let permissions = state + .authorization_service + .list_permissions_for_user(tenant_id, user_id) + .await?; + Ok(AppResponse::ok(permissions)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 81a50d8..8320205 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,48 +1,28 @@ -use crate::middleware::TenantId; -use crate::models::{CreateUserRequest, UserResponse}; -use crate::services::AuthService; -use axum::{Json, extract::State}; -use common_telemetry::AppError; // 引入刚刚写的中间件类型 +pub mod authorization; +pub mod auth; +pub mod role; +pub mod tenant; +pub mod user; + +use crate::services::{AuthService, AuthorizationService, RoleService, TenantService, UserService}; + +pub use auth::{login_handler, register_handler}; +pub use authorization::my_permissions_handler; +pub use role::{create_role_handler, list_roles_handler}; +pub use tenant::{ + create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler, + update_tenant_status_handler, +}; +pub use user::{ + delete_user_handler, get_user_handler, list_users_handler, update_user_handler, +}; // 状态对象,包含 Service #[derive(Clone)] pub struct AppState { pub auth_service: AuthService, -} - -/// 注册接口 -#[utoipa::path( - post, - path = "/auth/register", - request_body = CreateUserRequest, - responses( - (status = 201, description = "User created", body = UserResponse), - (status = 400, description = "Bad request") - ), - params( - ("X-Tenant-ID" = String, Header, description = "Tenant UUID") - ) -)] -pub async fn register_handler( - // 1. 自动注入 TenantId (由中间件解析) - TenantId(tenant_id): TenantId, - // 2. 获取全局状态中的 Service - State(state): State, - // 3. 获取 Body - Json(payload): Json, -) -> Result, AppError> { - let user = state - .auth_service - .register(tenant_id, payload) - .await - .map_err(AppError::BadRequest)?; - - // 转换为 Response DTO (隐藏密码等敏感信息) - let response = UserResponse { - id: user.id, - email: user.email.clone(), - // ... - }; - - Ok(Json(response)) + pub user_service: UserService, + pub role_service: RoleService, + pub tenant_service: TenantService, + pub authorization_service: AuthorizationService, } diff --git a/src/handlers/role.rs b/src/handlers/role.rs new file mode 100644 index 0000000..850d5f0 --- /dev/null +++ b/src/handlers/role.rs @@ -0,0 +1,132 @@ +use crate::handlers::AppState; +use crate::middleware::TenantId; +use crate::middleware::auth::AuthContext; +use crate::models::{CreateRoleRequest, RoleResponse}; +use axum::{Json, extract::State}; +use common_telemetry::{AppError, AppResponse}; +use tracing::instrument; + +#[utoipa::path( + post, + path = "/roles", + tag = "Role", + security( + ("bearer_auth" = []) + ), + request_body = CreateRoleRequest, + responses( + (status = 201, description = "角色创建成功", body = RoleResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state, payload))] +/// 在当前租户下创建角色。 +/// +/// 业务规则: +/// - 角色归属到当前租户(由 `TenantId` 决定),禁止跨租户写入。 +/// - 需要具备 `role:write` 权限,否则返回 403。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// - Body `CreateRoleRequest`(必填) +/// +/// 输出: +/// - `201`:返回新建角色信息(含 `id`) +/// +/// 异常: +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +/// - `400`:请求参数错误 +pub async fn create_role_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Json(payload): Json, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "role:write") + .await?; + + let role = state.role_service.create_role(tenant_id, payload).await?; + Ok(AppResponse::created(RoleResponse { + id: role.id, + name: role.name, + description: role.description, + })) +} + +#[utoipa::path( + get, + path = "/roles", + tag = "Role", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "角色列表", body = [RoleResponse]), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state))] +/// 查询当前租户下的角色列表。 +/// +/// 业务规则: +/// - 仅返回当前租户角色;若 `X-Tenant-ID` 与 Token 不一致则返回 403。 +/// - 需要具备 `role:read` 权限。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// +/// 输出: +/// - `200`:角色列表 +/// +/// 异常: +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +pub async fn list_roles_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, +) -> Result>, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "role:read") + .await?; + + let roles = state.role_service.list_roles(tenant_id).await?; + let response = roles + .into_iter() + .map(|r| RoleResponse { + id: r.id, + name: r.name, + description: r.description, + }) + .collect(); + Ok(AppResponse::ok(response)) +} diff --git a/src/handlers/tenant.rs b/src/handlers/tenant.rs new file mode 100644 index 0000000..77a9150 --- /dev/null +++ b/src/handlers/tenant.rs @@ -0,0 +1,294 @@ +use crate::handlers::AppState; +use crate::middleware::TenantId; +use crate::middleware::auth::AuthContext; +use crate::models::{ + CreateTenantRequest, TenantResponse, UpdateTenantRequest, UpdateTenantStatusRequest, +}; +use axum::{Json, extract::State}; +use common_telemetry::{AppError, AppResponse}; +use tracing::instrument; + +#[utoipa::path( + post, + path = "/tenants/register", + tag = "Tenant", + request_body = CreateTenantRequest, + responses( + (status = 201, description = "租户创建成功", body = TenantResponse), + (status = 400, description = "请求参数错误") + ) +)] +#[instrument(skip(state, payload))] +/// 创建租户(公开接口)。 +/// +/// 业务规则: +/// - 新租户默认 `status=active`。 +/// - `config` 未提供时默认 `{}`。 +/// +/// 输入: +/// - Body `CreateTenantRequest`(必填) +/// +/// 输出: +/// - `201`:返回新建租户信息(含 `id`) +/// +/// 异常: +/// - `400`:请求参数错误 +pub async fn create_tenant_handler( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let tenant = state.tenant_service.create_tenant(payload).await?; + let response = TenantResponse { + id: tenant.id, + name: tenant.name, + status: tenant.status, + config: tenant.config, + }; + Ok(AppResponse::created(response)) +} + +#[utoipa::path( + get, + path = "/tenants/me", + tag = "Tenant", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "获取当前租户信息", body = TenantResponse), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state))] +/// 获取当前登录用户所属租户的信息。 +/// +/// 业务规则: +/// - 若同时提供 `X-Tenant-ID` 与 Token 中租户不一致,返回 403(tenant:mismatch)。 +/// - 需要具备 `tenant:read` 权限。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// - Header `X-Tenant-ID`(可选;若提供需与 Token 一致) +/// +/// 输出: +/// - `200`:租户信息 +/// +/// 异常: +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +pub async fn get_tenant_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "tenant:read") + .await?; + let tenant = state.tenant_service.get_tenant(tenant_id).await?; + let response = TenantResponse { + id: tenant.id, + name: tenant.name, + status: tenant.status, + config: tenant.config, + }; + Ok(AppResponse::ok(response)) +} + +#[utoipa::path( + patch, + path = "/tenants/me", + tag = "Tenant", + security( + ("bearer_auth" = []) + ), + request_body = UpdateTenantRequest, + responses( + (status = 200, description = "租户更新成功", body = TenantResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state, payload))] +/// 更新当前租户的基础信息(名称 / 配置)。 +/// +/// 业务规则: +/// - 只允许更新当前登录租户;租户不一致返回 403。 +/// - 需要具备 `tenant:write` 权限。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// - Body `UpdateTenantRequest`:`name` / `config` 为可选字段,未提供则保持不变 +/// +/// 输出: +/// - `200`:更新后的租户信息 +/// +/// 异常: +/// - `400`:请求参数错误 +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +pub async fn update_tenant_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Json(payload): Json, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "tenant:write") + .await?; + let tenant = state + .tenant_service + .update_tenant(tenant_id, payload) + .await?; + let response = TenantResponse { + id: tenant.id, + name: tenant.name, + status: tenant.status, + config: tenant.config, + }; + Ok(AppResponse::ok(response)) +} + +#[utoipa::path( + post, + path = "/tenants/me/status", + tag = "Tenant", + security( + ("bearer_auth" = []) + ), + request_body = UpdateTenantStatusRequest, + responses( + (status = 200, description = "租户状态更新成功", body = TenantResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state, payload))] +/// 更新当前租户状态(如 active / disabled)。 +/// +/// 业务规则: +/// - 只允许更新当前登录租户;租户不一致返回 403。 +/// - 需要具备 `tenant:write` 权限。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// - Body `UpdateTenantStatusRequest.status`(必填) +/// +/// 输出: +/// - `200`:更新后的租户信息 +/// +/// 异常: +/// - `400`:请求参数错误 +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +pub async fn update_tenant_status_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Json(payload): Json, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "tenant:write") + .await?; + let tenant = state + .tenant_service + .update_tenant_status(tenant_id, payload) + .await?; + let response = TenantResponse { + id: tenant.id, + name: tenant.name, + status: tenant.status, + config: tenant.config, + }; + Ok(AppResponse::ok(response)) +} + +#[utoipa::path( + delete, + path = "/tenants/me", + tag = "Tenant", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "租户删除成功"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "未找到") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)") + ) +)] +#[instrument(skip(state))] +/// 删除当前租户。 +/// +/// 业务规则: +/// - 只允许删除当前登录租户;租户不一致返回 403。 +/// - 需要具备 `tenant:write` 权限。 +/// +/// 输出: +/// - `200`:删除成功(空响应) +/// +/// 异常: +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +/// - `404`:租户不存在 +pub async fn delete_tenant_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "tenant:write") + .await?; + state.tenant_service.delete_tenant(tenant_id).await?; + Ok(AppResponse::ok_empty()) +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs new file mode 100644 index 0000000..606b4c4 --- /dev/null +++ b/src/handlers/user.rs @@ -0,0 +1,293 @@ +use crate::handlers::AppState; +use crate::middleware::TenantId; +use crate::middleware::auth::AuthContext; +use crate::models::{UpdateUserRequest, UserResponse}; +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use common_telemetry::{AppError, AppResponse}; +use serde::Deserialize; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct ListUsersQuery { + pub page: Option, + pub page_size: Option, +} + +#[utoipa::path( + get, + path = "/users", + tag = "User", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "用户列表", body = [UserResponse]), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"), + ("page" = Option, Query, description = "页码,默认 1"), + ("page_size" = Option, Query, description = "每页数量,默认 20,最大 200") + ) +)] +#[instrument(skip(state))] +/// 分页查询当前租户下的用户列表。 +/// +/// 业务规则: +/// - 仅返回当前租户用户;租户不一致返回 403。 +/// - 需要具备 `user:read` 权限。 +/// - 分页参数约束:`page>=1`,`page_size` 范围 `1..=200`。 +/// +/// 输入: +/// - Header `Authorization: Bearer `(必填) +/// - Query `page` / `page_size`(可选) +/// +/// 输出: +/// - `200`:用户列表 +/// +/// 异常: +/// - `400`:分页参数非法 +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +pub async fn list_users_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Query(query): Query, +) -> Result>, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "user:read") + .await?; + + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + if page == 0 || page_size == 0 || page_size > 200 { + return Err(AppError::BadRequest("Invalid pagination parameters".into())); + } + + let users = state + .user_service + .list_users(tenant_id, page, page_size) + .await?; + let response = users + .into_iter() + .map(|u| UserResponse { + id: u.id, + email: u.email, + }) + .collect(); + Ok(AppResponse::ok(response)) +} + +#[utoipa::path( + get, + path = "/users/{id}", + tag = "User", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "用户详情", body = UserResponse), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "未找到") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"), + ("id" = String, Path, description = "用户 UUID") + ) +)] +#[instrument(skip(state))] +/// 根据用户 ID 查询用户详情。 +/// +/// 业务规则: +/// - 仅允许查询当前租户用户;租户不一致返回 403。 +/// - 需要具备 `user:read` 权限。 +/// +/// 输入: +/// - Path `id`:用户 UUID +/// - Header `Authorization: Bearer `(必填) +/// +/// 输出: +/// - `200`:用户信息 +/// +/// 异常: +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +/// - `404`:用户不存在 +pub async fn get_user_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(target_user_id): Path, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "user:read") + .await?; + + let user = state + .user_service + .get_user_by_id(tenant_id, target_user_id) + .await?; + Ok(AppResponse::ok(UserResponse { + id: user.id, + email: user.email, + })) +} + +#[utoipa::path( + patch, + path = "/users/{id}", + tag = "User", + security( + ("bearer_auth" = []) + ), + request_body = UpdateUserRequest, + responses( + (status = 200, description = "用户更新成功", body = UserResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "未找到") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"), + ("id" = String, Path, description = "用户 UUID") + ) +)] +#[instrument(skip(state, payload))] +/// 更新指定用户信息(目前支持更新邮箱)。 +/// +/// 业务规则: +/// - 仅允许更新当前租户用户;租户不一致返回 403。 +/// - 需要具备 `user:write` 权限。 +/// - `UpdateUserRequest` 中未提供的字段保持不变。 +/// +/// 输入: +/// - Path `id`:用户 UUID +/// - Header `Authorization: Bearer `(必填) +/// - Body `UpdateUserRequest`(必填) +/// +/// 输出: +/// - `200`:更新后的用户信息 +/// +/// 异常: +/// - `400`:请求参数错误 +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +/// - `404`:用户不存在 +pub async fn update_user_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(target_user_id): Path, + Json(payload): Json, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "user:write") + .await?; + + let user = state + .user_service + .update_user(tenant_id, target_user_id, payload) + .await?; + Ok(AppResponse::ok(UserResponse { + id: user.id, + email: user.email, + })) +} + +#[utoipa::path( + delete, + path = "/users/{id}", + tag = "User", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "用户删除成功"), + (status = 401, description = "未认证"), + (status = 403, description = "无权限"), + (status = 404, description = "未找到") + ), + params( + ("Authorization" = String, Header, description = "Bearer (访问令牌)"), + ("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"), + ("id" = String, Path, description = "用户 UUID") + ) +)] +#[instrument(skip(state))] +/// 删除指定用户。 +/// +/// 业务规则: +/// - 仅允许删除当前租户用户;租户不一致返回 403。 +/// - 需要具备 `user:write` 权限。 +/// +/// 输入: +/// - Path `id`:用户 UUID +/// - Header `Authorization: Bearer `(必填) +/// +/// 输出: +/// - `200`:删除成功(空响应) +/// +/// 异常: +/// - `401`:未认证 +/// - `403`:租户不匹配或无权限 +/// - `404`:用户不存在 +pub async fn delete_user_handler( + TenantId(tenant_id): TenantId, + State(state): State, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(target_user_id): Path, +) -> Result, AppError> { + if auth_tenant_id != tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + state + .authorization_service + .require_permission(tenant_id, user_id, "user:write") + .await?; + + state + .user_service + .delete_user(tenant_id, target_user_id) + .await?; + Ok(AppResponse::ok_empty()) +} diff --git a/src/main.rs b/src/main.rs index 6503af3..4a2d0ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,57 @@ mod config; mod db; // 声明 db 模块 +mod docs; mod handlers; mod middleware; mod models; mod services; mod utils; -use axum::{Router, middleware::from_fn, routing::post}; +use axum::{ + Router, + http::StatusCode, + middleware::from_fn, + routing::{get, post}, +}; use config::AppConfig; -use handlers::{AppState, register_handler}; -use services::AuthService; +use handlers::{ + AppState, create_role_handler, create_tenant_handler, delete_tenant_handler, + delete_user_handler, get_tenant_handler, get_user_handler, list_roles_handler, + list_users_handler, login_handler, my_permissions_handler, register_handler, + update_tenant_handler, update_tenant_status_handler, update_user_handler, +}; +use services::{AuthService, AuthorizationService, RoleService, TenantService, UserService}; use std::net::SocketAddr; use utoipa::OpenApi; use utoipa_scalar::{Scalar, Servable}; // 引入 models 下的所有结构体以生成文档 use common_telemetry::telemetry::{self, TelemetryConfig}; -use models::*; - -#[derive(OpenApi)] -#[openapi( - paths(handlers::register_handler), - components(schemas(CreateUserRequest, UserResponse)), - tags((name = "auth", description = "Authentication API")) -)] -struct ApiDoc; +use docs::ApiDoc; #[tokio::main] async fn main() { // 1. 加载配置 dotenvy::dotenv().ok(); - let config = AppConfig::from_env(); + let config = match AppConfig::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load configuration: {}", e); + std::process::exit(1); + } + }; + let telemetry_config = TelemetryConfig { - service_name: config.service_name, - log_level: config.log_level, + service_name: config.service_name.clone(), + log_level: config.log_level.clone(), log_to_file: config.log_to_file, - log_dir: Some(config.log_dir), - log_file: Some(config.log_file_name), + log_dir: Some(config.log_dir.clone()), + log_file: Some(config.log_file_name.clone()), }; // 2. 初始化 Tracing let _guard = telemetry::init(telemetry_config); // 3. 初始化数据库 (使用 db 模块) - let pool = match db::init_pool(&config.database_url).await { + let pool = match db::init_pool(&config).await { Ok(p) => p, Err(e) => { // 记录到日志文件和控制台 @@ -57,15 +67,60 @@ async fn main() { // 4. 初始化 Service 和 AppState let auth_service = AuthService::new(pool.clone(), config.jwt_secret.clone()); - let state = AppState { auth_service }; + let user_service = UserService::new(pool.clone()); + let role_service = RoleService::new(pool.clone()); + let tenant_service = TenantService::new(pool.clone()); + let authorization_service = AuthorizationService::new(pool.clone()); + + let state = AppState { + auth_service, + user_service, + role_service, + tenant_service, + authorization_service, + }; // 5. 构建路由 - let app = Router::new() - .route("/auth/register", post(register_handler)) - // 挂载多租户中间件 + let api = Router::new() + .route("/tenants/register", post(create_tenant_handler)) + .route( + "/tenants/me", + get(get_tenant_handler) + .patch(update_tenant_handler) + .delete(delete_tenant_handler), + ) + .route("/tenants/me/status", post(update_tenant_status_handler)) + .route( + "/auth/register", + post(register_handler) + .layer(middleware::rate_limit::register_rate_limiter()) + .layer(from_fn(middleware::rate_limit::log_rate_limit_register)), + ) + .route( + "/auth/login", + post(login_handler) + .layer(middleware::rate_limit::login_rate_limiter()) + .layer(from_fn(middleware::rate_limit::log_rate_limit_login)), + ) + .route("/me/permissions", get(my_permissions_handler)) + .route("/users", get(list_users_handler)) + .route( + "/users/{id}", + get(get_user_handler) + .patch(update_user_handler) + .delete(delete_user_handler), + ) + .route("/roles", get(list_roles_handler).post(create_role_handler)) .layer(from_fn(middleware::resolve_tenant)) - // 挂载 Scalar 文档 + .layer(from_fn(middleware::auth::authenticate)) + .layer(from_fn( + common_telemetry::axum_middleware::trace_http_request, + )); + + let app = Router::new() + .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT })) .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) + .merge(api) .with_state(state); // 6. 启动服务器 @@ -74,5 +129,10 @@ async fn main() { tracing::info!("📄 Docs available at http://{}/scalar", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); } diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..e40c57a --- /dev/null +++ b/src/middleware/auth.rs @@ -0,0 +1,67 @@ +use axum::{ + extract::{FromRequestParts, Request}, + http::request::Parts, + middleware::Next, + response::Response, +}; +use common_telemetry::AppError; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct AuthContext { + pub tenant_id: Uuid, + pub user_id: Uuid, + pub roles: Vec, + pub permissions: Vec, +} + +pub async fn authenticate(mut req: Request, next: Next) -> Result { + let path = req.uri().path(); + if path.starts_with("/scalar") + || path == "/tenants/register" + || path == "/auth/register" + || path == "/auth/login" + { + return Ok(next.run(req).await); + } + + let token = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::MissingAuthHeader)?; + + let claims = crate::utils::verify(token)?; + let tenant_id = Uuid::parse_str(&claims.tenant_id) + .map_err(|_| AppError::AuthError("Invalid tenant_id claim".into()))?; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| AppError::AuthError("Invalid sub claim".into()))?; + + tracing::Span::current().record("tenant_id", tracing::field::display(tenant_id)); + tracing::Span::current().record("user_id", tracing::field::display(user_id)); + + req.extensions_mut().insert(AuthContext { + tenant_id, + user_id, + roles: claims.roles, + permissions: claims.permissions, + }); + + Ok(next.run(req).await) +} + +impl FromRequestParts for AuthContext +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(AppError::MissingAuthHeader) + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index a7055d4..0b0a052 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1,5 +1,9 @@ +pub mod auth; +pub mod rate_limit; + use axum::extract::FromRequestParts; -use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; +use axum::{extract::Request, middleware::Next, response::Response}; +use common_telemetry::AppError; use http::request::Parts; use uuid::Uuid; @@ -8,7 +12,32 @@ use uuid::Uuid; #[derive(Clone, Debug)] // 这是一个类型安全的 Wrapper,用于在 Handler 中注入 pub struct TenantId(pub Uuid); -pub async fn resolve_tenant(mut req: Request, next: Next) -> Result { +pub async fn resolve_tenant(mut req: Request, next: Next) -> Result { + let path = req.uri().path(); + if path.starts_with("/scalar") || path == "/tenants/register" { + return Ok(next.run(req).await); + } + + if let Some(auth_tenant_id) = req + .extensions() + .get::() + .map(|ctx| ctx.tenant_id) + { + if let Some(header_value) = req + .headers() + .get("X-Tenant-ID") + .and_then(|val| val.to_str().ok()) + { + let header_tenant_id = Uuid::parse_str(header_value) + .map_err(|_| AppError::BadRequest("Invalid X-Tenant-ID format".into()))?; + if header_tenant_id != auth_tenant_id { + return Err(AppError::PermissionDenied("tenant:mismatch".into())); + } + } + tracing::Span::current().record("tenant_id", tracing::field::display(auth_tenant_id)); + req.extensions_mut().insert(TenantId(auth_tenant_id)); + return Ok(next.run(req).await); + } // 尝试从 Header 获取 X-Tenant-ID let tenant_id_str = req .headers() @@ -18,17 +47,18 @@ pub async fn resolve_tenant(mut req: Request, next: Next) -> Result { if let Ok(uuid) = Uuid::parse_str(id_str) { + tracing::Span::current().record("tenant_id", tracing::field::display(uuid)); // 验证成功,注入到 Extension 中 req.extensions_mut().insert(TenantId(uuid)); Ok(next.run(req).await) } else { - Err(StatusCode::BAD_REQUEST) // ID 格式错误 + Err(AppError::BadRequest("Invalid X-Tenant-ID format".into())) } } None => { // 如果是公开接口(如登录注册),可能不需要 TenantID,视业务而定 // 这里假设严格模式,必须带 TenantID - Err(StatusCode::BAD_REQUEST) + Err(AppError::BadRequest("Missing X-Tenant-ID header".into())) } } } @@ -38,9 +68,12 @@ impl FromRequestParts for TenantId where S: Send + Sync, { - type Rejection = StatusCode; + type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + if let Some(tid) = parts.extensions.get::().cloned() { + return Ok(tid); + } let tenant_id_str = parts .headers .get("X-Tenant-ID") @@ -49,8 +82,8 @@ where match tenant_id_str { Some(id_str) => uuid::Uuid::parse_str(id_str) .map(TenantId) - .map_err(|_| StatusCode::BAD_REQUEST), - None => Err(StatusCode::BAD_REQUEST), + .map_err(|_| AppError::BadRequest("Invalid X-Tenant-ID format".into())), + None => Err(AppError::BadRequest("Missing X-Tenant-ID header".into())), } } } diff --git a/src/middleware/rate_limit.rs b/src/middleware/rate_limit.rs new file mode 100644 index 0000000..cfc1177 --- /dev/null +++ b/src/middleware/rate_limit.rs @@ -0,0 +1,369 @@ +use axum::{ + extract::Request, + http::{HeaderValue, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use common_telemetry::AppError; +use governor::middleware::StateInformationMiddleware; +use ipnet::IpNet; +use std::net::IpAddr; +use std::sync::OnceLock; +use std::time::Duration; +use tower_governor::GovernorLayer; +use tower_governor::errors::GovernorError; +use tower_governor::governor::GovernorConfigBuilder; +use tower_governor::key_extractor::{KeyExtractor, PeerIpKeyExtractor, SmartIpKeyExtractor}; +use tracing::Instrument; + +#[derive(Clone, Debug)] +pub(crate) struct TrustedProxySmartIpKeyExtractor { + trusted_proxies: Vec, +} + +impl TrustedProxySmartIpKeyExtractor { + fn from_env() -> Self { + static TRUSTED: OnceLock> = OnceLock::new(); + let trusted_proxies = TRUSTED + .get_or_init(|| { + let raw = std::env::var("TRUSTED_PROXY_CIDRS").unwrap_or_default(); + if raw.trim().is_empty() { + return Vec::new(); + } + raw.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .unwrap_or_else(|_| panic!("Invalid TRUSTED_PROXY_CIDRS entry: {s}")) + }) + .collect::>() + }) + .clone(); + + Self { trusted_proxies } + } + + fn is_trusted_proxy(&self, peer_ip: IpAddr) -> bool { + self.trusted_proxies + .iter() + .any(|cidr| cidr.contains(&peer_ip)) + } +} + +impl KeyExtractor for TrustedProxySmartIpKeyExtractor { + type Key = IpAddr; + + fn extract(&self, req: &http::Request) -> Result { + let peer_ip = PeerIpKeyExtractor.extract(req)?; + if self.is_trusted_proxy(peer_ip) { + SmartIpKeyExtractor.extract(req) + } else { + Ok(peer_ip) + } + } +} + +fn login_policy() -> (&'static str, Duration, u32) { + ("auth.login", Duration::from_millis(500), 10) +} + +fn register_policy() -> (&'static str, Duration, u32) { + ("auth.register", Duration::from_secs(1), 5) +} + +fn governor_headers(err: &GovernorError) -> Option { + match err { + GovernorError::TooManyRequests { headers, .. } => headers.clone(), + GovernorError::Other { headers, .. } => headers.clone(), + GovernorError::UnableToExtractKey => None, + } +} + +fn governor_wait_time_seconds(err: &GovernorError) -> Option { + match err { + GovernorError::TooManyRequests { wait_time, .. } => Some(*wait_time), + _ => None, + } +} + +fn rate_limit_error_response(err: GovernorError) -> Response { + let mut resp = AppError::RateLimitExceeded.into_response(); + if let Some(headers) = governor_headers(&err) { + resp.headers_mut().extend(headers); + } + if let Some(wait_time) = governor_wait_time_seconds(&err) { + resp.headers_mut().insert( + http::header::RETRY_AFTER, + HeaderValue::from_str(&wait_time.to_string()).unwrap_or(HeaderValue::from_static("1")), + ); + } + resp +} + +fn header_u64(resp: &Response, name: &'static str) -> Option { + resp.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) +} + +fn ip_for_log(req: &Request) -> Option { + TrustedProxySmartIpKeyExtractor::from_env() + .extract(req) + .ok() +} + +async fn log_rate_limited( + policy: (&'static str, Duration, u32), + req: Request, + next: Next, +) -> Response { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let client_ip = ip_for_log(&req); + let auth = req + .extensions() + .get::() + .cloned(); + + let resp = next.run(req).await; + if resp.status() != StatusCode::TOO_MANY_REQUESTS { + return resp; + } + + let (policy_name, period, burst_size) = policy; + let retry_after = header_u64(&resp, "retry-after"); + let limit = header_u64(&resp, "x-ratelimit-limit"); + let remaining = header_u64(&resp, "x-ratelimit-remaining"); + let wait = header_u64(&resp, "x-ratelimit-after").or(retry_after); + let used = match (limit, remaining) { + (Some(l), Some(r)) if l >= r => Some(l - r), + _ => None, + }; + + let span = tracing::Span::current(); + async move { + tracing::error!( + event = "rate_limit_triggered", + policy = policy_name, + method = %method, + path = %path, + client_ip = %client_ip.map(|ip| ip.to_string()).unwrap_or_else(|| "unknown".into()), + tenant_id = %auth.as_ref().map(|a| a.tenant_id.to_string()).unwrap_or_else(|| "unknown".into()), + user_id = %auth.as_ref().map(|a| a.user_id.to_string()).unwrap_or_else(|| "anonymous".into()), + burst_size = burst_size, + period_ms = period.as_millis() as u64, + retry_after_s = retry_after, + limit = limit, + remaining = remaining, + used = used, + wait_s = wait + ); + } + .instrument(span) + .await; + + resp +} + +pub async fn log_rate_limit_login(req: Request, next: Next) -> Response { + log_rate_limited(login_policy(), req, next).await +} + +pub async fn log_rate_limit_register(req: Request, next: Next) -> Response { + log_rate_limited(register_policy(), req, next).await +} + +pub fn login_rate_limiter() +-> GovernorLayer { + let (policy, period, burst) = login_policy(); + let mut config = GovernorConfigBuilder::default(); + config.period(period); + config.burst_size(burst); + let mut config = config.use_headers(); + let config = config + .key_extractor(TrustedProxySmartIpKeyExtractor::from_env()) + .finish() + .unwrap_or_else(|| panic!("failed to build rate limiter config: {policy}")); + + GovernorLayer::new(config).error_handler(rate_limit_error_response) +} + +pub fn register_rate_limiter() +-> GovernorLayer { + let (policy, period, burst) = register_policy(); + let mut config = GovernorConfigBuilder::default(); + config.period(period); + config.burst_size(burst); + let mut config = config.use_headers(); + let config = config + .key_extractor(TrustedProxySmartIpKeyExtractor::from_env()) + .finish() + .unwrap_or_else(|| panic!("failed to build rate limiter config: {policy}")); + + GovernorLayer::new(config).error_handler(rate_limit_error_response) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + Router, + extract::ConnectInfo, + http::{Request, StatusCode}, + middleware::from_fn, + routing::post, + }; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + use tower::ServiceExt; + + async fn ok_handler() -> StatusCode { + StatusCode::OK + } + + #[tokio::test] + async fn login_rate_limiter_eventually_returns_429() { + let app = Router::new().route("/auth/login", post(ok_handler).layer(login_rate_limiter())); + + let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + + let mut saw_429 = false; + for _ in 0..32 { + let mut req = Request::builder() + .method("POST") + .uri("/auth/login") + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + let resp = app.clone().oneshot(req).await.unwrap(); + if resp.status() == StatusCode::TOO_MANY_REQUESTS { + saw_429 = true; + break; + } + } + + assert!(saw_429); + } + + #[tokio::test] + async fn register_rate_limiter_allows_burst_then_limits() { + let app = Router::new().route( + "/auth/register", + post(ok_handler).layer(register_rate_limiter()), + ); + + let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + + for _ in 0..5 { + let mut req = Request::builder() + .method("POST") + .uri("/auth/register") + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + let mut req = Request::builder() + .method("POST") + .uri("/auth/register") + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn register_rate_limiter_recovers_after_wait() { + let app = Router::new().route( + "/auth/register", + post(ok_handler).layer(register_rate_limiter()), + ); + + let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + for _ in 0..6 { + let mut req = Request::builder() + .method("POST") + .uri("/auth/register") + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + let _ = app.clone().oneshot(req).await.unwrap(); + } + + tokio::time::sleep(Duration::from_millis(1100)).await; + + let mut req = Request::builder() + .method("POST") + .uri("/auth/register") + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn rate_limit_log_contains_request_context() { + struct BufferWriter(Arc>>); + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufferWriter { + type Writer = BufferGuard; + fn make_writer(&'a self) -> Self::Writer { + BufferGuard(self.0.clone()) + } + } + + struct BufferGuard(Arc>>); + + impl std::io::Write for BufferGuard { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let buf = Arc::new(Mutex::new(Vec::::new())); + let subscriber = tracing_subscriber::fmt() + .with_writer(BufferWriter(buf.clone())) + .with_ansi(false) + .json() + .finish(); + + let app = Router::new().route( + "/auth/register", + post(ok_handler) + .layer(register_rate_limiter()) + .layer(from_fn(log_rate_limit_register)), + ); + + let _guard = tracing::subscriber::set_default(subscriber); + { + let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + for _ in 0..6 { + let mut req = Request::builder() + .method("POST") + .uri("/auth/register") + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(ConnectInfo(addr)); + let _ = app.clone().oneshot(req).await.unwrap(); + } + } + + let s = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + assert!(s.contains("\"event\":\"rate_limit_triggered\"")); + assert!(s.contains("\"path\":\"/auth/register\"")); + assert!(s.contains("\"method\":\"POST\"")); + assert!(s.contains("\"policy\":\"auth.register\"")); + assert!(s.contains("\"client_ip\":\"127.0.0.1\"")); + } +} diff --git a/src/models.rs b/src/models.rs index 5e99723..d7dc927 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,30 +1,194 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; use sqlx::FromRow; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; // 关键引入 -#[derive(Debug, Serialize, FromRow, ToSchema)] -pub struct User { - #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] - pub id: Uuid, - #[schema(example = "11111111-1111-1111-1111-111111111111")] - pub tenant_id: Uuid, - #[schema(example = "user@example.com")] - pub email: String, - #[schema(ignore)] // 不在文档中显示密码哈希 - pub password_hash: String, +#[allow(dead_code)] +fn default_uuid() -> Uuid { + Uuid::nil() } -#[derive(Debug, Deserialize, ToSchema)] +#[allow(dead_code)] +fn default_json_object() -> Value { + Value::Object(Default::default()) +} + +#[allow(dead_code)] +fn default_token_type() -> String { + "Bearer".to_string() +} + +#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)] +pub struct User { + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub id: Uuid, + #[schema(example = "11111111-1111-1111-1111-111111111111")] + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub tenant_id: Uuid, + #[schema(example = "user@example.com")] + #[schema(default = "")] + #[serde(default)] + pub email: String, + #[schema(ignore)] // 不在文档中显示密码哈希 + #[serde(default)] + pub password_hash: String, + // created_at, updated_at, status etc. could be added later +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] pub struct CreateUserRequest { #[schema(example = "user@example.com")] + #[schema(default = "")] + #[serde(default)] pub email: String, #[schema(example = "securePassword123")] + #[schema(default = "")] + #[serde(default)] pub password: String, } -#[derive(Debug, Serialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct UpdateUserRequest { + #[schema(example = "new_email@example.com")] + #[serde(default)] + pub email: Option, + // Add other fields like name, phone, etc. +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] pub struct UserResponse { + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] pub id: Uuid, + #[schema(default = "")] + #[serde(default)] pub email: String, } + +// --- Auth Related Models --- + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct LoginRequest { + #[schema(example = "user@example.com")] + #[schema(default = "")] + #[serde(default)] + pub email: String, + #[schema(example = "securePassword123")] + #[schema(default = "")] + #[serde(default)] + pub password: String, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +pub struct LoginResponse { + #[schema(default = "")] + #[serde(default)] + pub access_token: String, + #[schema(default = "")] + #[serde(default)] + pub refresh_token: String, + #[schema(default = "Bearer", example = "Bearer")] + #[serde(default = "default_token_type")] + pub token_type: String, + #[schema(default = 0)] + #[serde(default)] + pub expires_in: usize, +} + +// --- Role Related Models --- + +#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)] +pub struct Role { + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub id: Uuid, + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub tenant_id: Uuid, + #[schema(default = "")] + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct CreateRoleRequest { + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +pub struct RoleResponse { + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub id: Uuid, + #[schema(default = "")] + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: Option, +} + +// --- Tenant Related Models --- + +#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)] +pub struct Tenant { + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub id: Uuid, + #[schema(default = "")] + #[serde(default)] + pub name: String, + #[schema(default = "active", example = "active")] + #[serde(default)] + pub status: String, + #[schema(default = default_json_object, example = default_json_object)] + #[serde(default = "default_json_object")] + pub config: Value, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct CreateTenantRequest { + #[serde(default)] + pub name: String, + #[serde(default)] + pub config: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct UpdateTenantRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub config: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct UpdateTenantStatusRequest { + #[schema(default = "active", example = "active")] + #[serde(default)] + pub status: String, +} + +#[derive(Debug, Serialize, ToSchema, IntoParams)] +pub struct TenantResponse { + #[schema(default = "00000000-0000-0000-0000-000000000000")] + #[serde(default = "default_uuid")] + pub id: Uuid, + #[schema(default = "")] + #[serde(default)] + pub name: String, + #[schema(default = "active", example = "active")] + #[serde(default)] + pub status: String, + #[schema(default = default_json_object, example = default_json_object)] + #[serde(default = "default_json_object")] + pub config: Value, +} diff --git a/src/services/auth.rs b/src/services/auth.rs new file mode 100644 index 0000000..5dd2a9b --- /dev/null +++ b/src/services/auth.rs @@ -0,0 +1,222 @@ +use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User}; +use crate::utils::{hash_password, sign, verify_password}; +use common_telemetry::AppError; +use rand::RngCore; +use sqlx::PgPool; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Clone)] +pub struct AuthService { + pool: PgPool, + // jwt_secret removed, using RS256 keys +} + +impl AuthService { + /// 创建认证服务实例。 + /// + /// 说明: + /// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留。 + pub fn new(pool: PgPool, _jwt_secret: String) -> Self { + Self { pool } + } + + // 注册业务 + #[instrument(skip(self, req))] + /// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。 + /// + /// 业务规则: + /// - 用户必须绑定到 `tenant_id`,禁止跨租户注册写入。 + /// - 密码以安全哈希形式存储,不回传明文。 + /// - 若该租户用户数为 1(首个用户),自动创建/获取 `Admin` 系统角色并授予全量权限,同时绑定到该用户。 + /// + /// 输入: + /// - `tenant_id`:目标租户 + /// - `req.email` / `req.password`:注册信息 + /// + /// 输出: + /// - 返回创建后的 `User` 记录(包含 `id/tenant_id/email` 等字段) + /// + /// 异常: + /// - 数据库写入失败(如唯一约束冲突、连接错误等) + /// - 密码哈希失败 + pub async fn register( + &self, + tenant_id: Uuid, + req: CreateUserRequest, + ) -> Result { + let mut tx = self.pool.begin().await?; + + // 1. 哈希密码 + let hashed = + hash_password(&req.password).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?; + + // 2. 存入数据库 (带上 tenant_id) + let query = r#" + INSERT INTO users (tenant_id, email, password_hash) + VALUES ($1, $2, $3) + RETURNING id, tenant_id, email, password_hash + "#; + let user = sqlx::query_as::<_, User>(query) + .bind(tenant_id) + .bind(&req.email) + .bind(&hashed) + .fetch_one(&mut *tx) + .await?; + + let user_count: i64 = sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1") + .bind(tenant_id) + .fetch_one(&mut *tx) + .await?; + + if user_count == 1 { + self.bootstrap_tenant_admin(&mut tx, tenant_id, user.id) + .await?; + } + + tx.commit().await?; + Ok(user) + } + + // 登录业务 + #[instrument(skip(self, req))] + /// 在指定租户内完成用户认证并签发访问令牌与刷新令牌。 + /// + /// 业务规则: + /// - 仅在当前租户内按 `email` 查找用户,防止跨租户登录。 + /// - 密码校验失败返回 `InvalidCredentials`。 + /// - 登录成功后生成: + /// - `access_token`:JWT(包含租户、用户、角色与权限) + /// - `refresh_token`:随机生成并哈希后入库(默认 30 天过期) + /// + /// 输出: + /// - `LoginResponse`(token_type 固定为 `Bearer`,`expires_in` 当前为 15 分钟) + /// + /// 异常: + /// - 用户不存在(404) + /// - 密码错误(401) + /// - Token 签发失败或数据库写入失败 + pub async fn login( + &self, + tenant_id: Uuid, + req: LoginRequest, + ) -> Result { + // 1. 查找用户 (带 tenant_id 防止跨租户登录) + let query = "SELECT * FROM users WHERE tenant_id = $1 AND email = $2"; + let user = sqlx::query_as::<_, User>(query) + .bind(tenant_id) + .bind(&req.email) + .fetch_optional(&self.pool) + .await? + .ok_or(AppError::NotFound("User not found".into()))?; + + // 2. 验证密码 + if !verify_password(&req.password, &user.password_hash) { + return Err(AppError::InvalidCredentials); + } + + let roles = sqlx::query_scalar::<_, String>( + r#" + SELECT r.name + FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE r.tenant_id = $1 AND ur.user_id = $2 + "#, + ) + .bind(user.tenant_id) + .bind(user.id) + .fetch_all(&self.pool) + .await?; + + let permissions = sqlx::query_scalar::<_, String>( + r#" + SELECT DISTINCT p.code + FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + JOIN roles r ON r.id = ur.role_id + WHERE r.tenant_id = $1 AND ur.user_id = $2 + "#, + ) + .bind(user.tenant_id) + .bind(user.id) + .fetch_all(&self.pool) + .await?; + + // 3. 签发 Access Token + let access_token = sign(user.id, user.tenant_id, roles, permissions)?; + + // 4. 生成 Refresh Token + let mut refresh_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut refresh_bytes); + let refresh_token = hex::encode(refresh_bytes); + + // Hash refresh token for storage + let refresh_token_hash = + hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?; + + // 5. 存储 Refresh Token (30天过期) + let expires_at = chrono::Utc::now() + chrono::Duration::days(30); + + sqlx::query( + "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + ) + .bind(user.id) + .bind(refresh_token_hash) + .bind(expires_at) + .execute(&self.pool) + .await?; + + Ok(LoginResponse { + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: 15 * 60, // 15 mins + }) + } + + async fn bootstrap_tenant_admin( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + let role_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO roles (tenant_id, name, description, is_system) + VALUES ($1, 'Admin', 'Tenant administrator', TRUE) + ON CONFLICT (tenant_id, name) + DO UPDATE SET name = EXCLUDED.name + RETURNING id + "#, + ) + .bind(tenant_id) + .fetch_one(&mut **tx) + .await?; + + sqlx::query( + r#" + INSERT INTO role_permissions (role_id, permission_id) + SELECT $1, p.id FROM permissions p + ON CONFLICT DO NOTHING + "#, + ) + .bind(role_id) + .execute(&mut **tx) + .await?; + + sqlx::query( + r#" + INSERT INTO user_roles (user_id, role_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + ) + .bind(user_id) + .bind(role_id) + .execute(&mut **tx) + .await?; + + Ok(()) + } +} diff --git a/src/services/authorization.rs b/src/services/authorization.rs new file mode 100644 index 0000000..dbceae6 --- /dev/null +++ b/src/services/authorization.rs @@ -0,0 +1,79 @@ +use common_telemetry::AppError; +use sqlx::PgPool; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Clone)] +pub struct AuthorizationService { + pool: PgPool, +} + +impl AuthorizationService { + /// 创建权限服务实例。 + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + #[instrument(skip(self))] + /// 获取用户在指定租户下的权限编码集合(去重)。 + /// + /// 说明: + /// - 权限来源于用户所属角色(user_roles → roles)及角色绑定权限(role_permissions → permissions)。 + /// + /// 输入: + /// - `tenant_id`:租户 ID + /// - `user_id`:用户 ID + /// + /// 输出: + /// - 权限编码数组(如 `tenant:read` / `user:write`) + /// + /// 异常: + /// - 数据库查询失败 + pub async fn list_permissions_for_user( + &self, + tenant_id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + let query = r#" + SELECT DISTINCT p.code + FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + JOIN roles r ON r.id = ur.role_id + WHERE r.tenant_id = $1 AND ur.user_id = $2 + "#; + let rows = sqlx::query_scalar::<_, String>(query) + .bind(tenant_id) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + #[instrument(skip(self))] + /// 校验用户是否具备指定权限,不满足则直接返回权限拒绝错误。 + /// + /// 业务规则: + /// - 若用户权限集合中不包含 `permission_code`,返回 `PermissionDenied(permission_code)`。 + /// + /// 输入: + /// - `tenant_id`:租户 ID + /// - `user_id`:用户 ID + /// - `permission_code`:权限编码 + /// + /// 输出: + /// - 成功返回 `()`;失败返回权限拒绝错误 + pub async fn require_permission( + &self, + tenant_id: Uuid, + user_id: Uuid, + permission_code: &str, + ) -> Result<(), AppError> { + let permissions = self.list_permissions_for_user(tenant_id, user_id).await?; + if permissions.iter().any(|p| p == permission_code) { + Ok(()) + } else { + Err(AppError::PermissionDenied(permission_code.to_string())) + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 2178e66..c877b51 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,69 +1,11 @@ -use crate::models::{CreateUserRequest, User}; // 假设你在 models 定义了这些 -use crate::utils::{create_jwt, hash_password, verify_password}; -use axum::Json; -use sqlx::PgPool; -use uuid::Uuid; +pub mod auth; +pub mod authorization; +pub mod role; +pub mod tenant; +pub mod user; -#[derive(Clone)] -pub struct AuthService { - pool: PgPool, - jwt_secret: String, -} - -impl AuthService { - pub fn new(pool: PgPool, jwt_secret: String) -> Self { - Self { pool, jwt_secret } - } - - // 注册业务 - pub async fn register( - &self, - tenant_id: Uuid, - req: CreateUserRequest, - ) -> Result, String> { - // 1. 哈希密码 - let hashed = hash_password(&req.password)?; - - // 2. 存入数据库 (带上 tenant_id) - let query = r#" - INSERT INTO users (tenant_id, email, password_hash) - VALUES ($1, $2, $3) - RETURNING id, tenant_id, email, password_hash, created_at - "#; - let user = sqlx::query_as::<_, User>(query) - .bind(tenant_id) - .bind(&req.email) - .bind(&hashed) - .fetch_one(&self.pool) - .await - .map_err(|e| e.to_string())?; - - Ok(Json(user)) - } - - // 登录业务 - pub async fn login( - &self, - tenant_id: Uuid, - email: &str, - password: &str, - ) -> Result { - // 1. 查找用户 (带 tenant_id 防止跨租户登录) - let query = "SELECT * FROM users WHERE tenant_id = $1 AND email = $2"; - let user = sqlx::query_as::<_, User>(query) - .bind(tenant_id) - .bind(email) - .fetch_optional(&self.pool) - .await - .map_err(|e| e.to_string())? - .ok_or("User not found")?; - - // 2. 验证密码 - if !verify_password(password, &user.password_hash) { - return Err("Invalid password".to_string()); - } - - // 3. 签发 Token - create_jwt(user.id, user.tenant_id, &self.jwt_secret) - } -} +pub use auth::AuthService; +pub use authorization::AuthorizationService; +pub use role::RoleService; +pub use tenant::TenantService; +pub use user::UserService; diff --git a/src/services/role.rs b/src/services/role.rs new file mode 100644 index 0000000..13407a4 --- /dev/null +++ b/src/services/role.rs @@ -0,0 +1,59 @@ +use crate::models::{CreateRoleRequest, Role}; +use common_telemetry::AppError; +use sqlx::PgPool; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Clone)] +pub struct RoleService { + pool: PgPool, +} + +impl RoleService { + /// 创建角色服务实例。 + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + #[instrument(skip(self))] + /// 在指定租户下创建角色记录。 + /// + /// 业务规则: + /// - 角色与租户强绑定(写入时携带 `tenant_id`)。 + /// + /// 异常: + /// - 数据库写入失败(如约束冲突、连接错误等) + pub async fn create_role( + &self, + tenant_id: Uuid, + req: CreateRoleRequest, + ) -> Result { + let query = r#" + INSERT INTO roles (tenant_id, name, description) + VALUES ($1, $2, $3) + RETURNING id, tenant_id, name, description + "#; + // Note: 'roles' table needs to be created in DB + sqlx::query_as::<_, Role>(query) + .bind(tenant_id) + .bind(req.name) + .bind(req.description) + .fetch_one(&self.pool) + .await + .map_err(|e| AppError::DbError(e)) + } + + #[instrument(skip(self))] + /// 查询指定租户下的角色列表。 + /// + /// 异常: + /// - 数据库查询失败 + pub async fn list_roles(&self, tenant_id: Uuid) -> Result, AppError> { + let query = "SELECT * FROM roles WHERE tenant_id = $1"; + sqlx::query_as::<_, Role>(query) + .bind(tenant_id) + .fetch_all(&self.pool) + .await + .map_err(|e| AppError::DbError(e)) + } +} diff --git a/src/services/tenant.rs b/src/services/tenant.rs new file mode 100644 index 0000000..25968df --- /dev/null +++ b/src/services/tenant.rs @@ -0,0 +1,134 @@ +use crate::models::{ + CreateTenantRequest, Tenant, UpdateTenantRequest, UpdateTenantStatusRequest, +}; +use common_telemetry::AppError; +use serde_json::Value; +use sqlx::PgPool; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Clone)] +pub struct TenantService { + pool: PgPool, +} + +impl TenantService { + /// 创建租户服务实例。 + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + #[instrument(skip(self, req))] + /// 创建新租户并初始化默认状态与配置。 + /// + /// 业务规则: + /// - 默认 `status=active`。 + /// - `config` 未提供时默认 `{}`。 + /// + /// 输出: + /// - 返回新建租户记录(含 `id`) + /// + /// 异常: + /// - 数据库写入失败(如连接异常、约束失败等) + pub async fn create_tenant(&self, req: CreateTenantRequest) -> Result { + let config = req.config.unwrap_or_else(|| Value::Object(Default::default())); + let query = r#" + INSERT INTO tenants (name, status, config) + VALUES ($1, 'active', $2) + RETURNING id, name, status, config + "#; + let tenant = sqlx::query_as::<_, Tenant>(query) + .bind(req.name) + .bind(config) + .fetch_one(&self.pool) + .await?; + Ok(tenant) + } + + #[instrument(skip(self))] + /// 根据租户 ID 查询租户信息。 + /// + /// 异常: + /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 + pub async fn get_tenant(&self, tenant_id: Uuid) -> Result { + let query = "SELECT id, name, status, config FROM tenants WHERE id = $1"; + sqlx::query_as::<_, Tenant>(query) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| AppError::NotFound("Tenant not found".into())) + } + + #[instrument(skip(self, req))] + /// 更新租户基础信息(名称 / 配置)。 + /// + /// 说明: + /// - 仅更新 `UpdateTenantRequest` 中提供的字段,未提供字段保持不变。 + /// + /// 异常: + /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 + pub async fn update_tenant( + &self, + tenant_id: Uuid, + req: UpdateTenantRequest, + ) -> Result { + let query = r#" + UPDATE tenants + SET + name = COALESCE($1, name), + config = COALESCE($2, config), + updated_at = NOW() + WHERE id = $3 + RETURNING id, name, status, config + "#; + sqlx::query_as::<_, Tenant>(query) + .bind(req.name) + .bind(req.config) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| AppError::NotFound("Tenant not found".into())) + } + + #[instrument(skip(self, req))] + /// 更新租户状态字段(如 active / disabled)。 + /// + /// 异常: + /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 + pub async fn update_tenant_status( + &self, + tenant_id: Uuid, + req: UpdateTenantStatusRequest, + ) -> Result { + let query = r#" + UPDATE tenants + SET + status = $1, + updated_at = NOW() + WHERE id = $2 + RETURNING id, name, status, config + "#; + sqlx::query_as::<_, Tenant>(query) + .bind(req.status) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| AppError::NotFound("Tenant not found".into())) + } + + #[instrument(skip(self))] + /// 删除指定租户。 + /// + /// 异常: + /// - 若租户不存在,返回 `NotFound("Tenant not found")`。 + pub async fn delete_tenant(&self, tenant_id: Uuid) -> Result<(), AppError> { + let result = sqlx::query("DELETE FROM tenants WHERE id = $1") + .bind(tenant_id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Tenant not found".into())); + } + Ok(()) + } +} diff --git a/src/services/user.rs b/src/services/user.rs new file mode 100644 index 0000000..cfcecd7 --- /dev/null +++ b/src/services/user.rs @@ -0,0 +1,109 @@ +use crate::models::{UpdateUserRequest, User}; +use common_telemetry::AppError; +use sqlx::PgPool; +use tracing::instrument; +use uuid::Uuid; + +#[derive(Clone)] +pub struct UserService { + pool: PgPool, +} + +impl UserService { + /// 创建用户服务实例。 + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + #[instrument(skip(self))] + /// 根据用户 ID 查询用户记录(限定在指定租户内)。 + /// + /// 业务规则: + /// - 查询条件同时包含 `tenant_id` 与 `user_id`,避免跨租户读取。 + /// + /// 异常: + /// - 用户不存在返回 `NotFound("User not found")` + pub async fn get_user_by_id(&self, tenant_id: Uuid, user_id: Uuid) -> Result { + let query = "SELECT * FROM users WHERE tenant_id = $1 AND id = $2"; + sqlx::query_as::<_, User>(query) + .bind(tenant_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| AppError::DbError(e))? + .ok_or_else(|| AppError::NotFound("User not found".into())) + } + + #[instrument(skip(self))] + /// 分页查询租户下用户列表。 + /// + /// 说明: + /// - `offset = (page - 1) * page_size`,由上层负责保证 `page>=1`。 + /// + /// 异常: + /// - 数据库查询失败 + pub async fn list_users( + &self, + tenant_id: Uuid, + page: u32, + page_size: u32, + ) -> Result, AppError> { + let offset = (page - 1) * page_size; + let query = "SELECT * FROM users WHERE tenant_id = $1 LIMIT $2 OFFSET $3"; + sqlx::query_as::<_, User>(query) + .bind(tenant_id) + .bind(page_size as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| AppError::DbError(e)) + } + + #[instrument(skip(self))] + /// 更新指定用户信息(目前仅支持邮箱字段)。 + /// + /// 业务规则: + /// - 查询条件同时包含 `tenant_id` 与 `user_id`,避免跨租户更新。 + /// - `UpdateUserRequest` 中未提供字段保持不变。 + /// + /// 异常: + /// - 用户不存在返回 `NotFound("User not found")` + pub async fn update_user( + &self, + tenant_id: Uuid, + user_id: Uuid, + req: UpdateUserRequest, + ) -> Result { + // Simple update implementation + // In a real app, you'd build the query dynamically based on Option fields + let query = "UPDATE users SET email = COALESCE($1, email) WHERE tenant_id = $2 AND id = $3 RETURNING *"; + sqlx::query_as::<_, User>(query) + .bind(req.email) + .bind(tenant_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| AppError::DbError(e))? + .ok_or_else(|| AppError::NotFound("User not found".into())) + } + + #[instrument(skip(self))] + /// 删除指定用户(限定在指定租户内)。 + /// + /// 异常: + /// - 用户不存在返回 `NotFound("User not found")` + pub async fn delete_user(&self, tenant_id: Uuid, user_id: Uuid) -> Result<(), AppError> { + let query = "DELETE FROM users WHERE tenant_id = $1 AND id = $2"; + let result = sqlx::query(query) + .bind(tenant_id) + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|e| AppError::DbError(e))?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("User not found".into())); + } + Ok(()) + } +} diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs new file mode 100644 index 0000000..e447a90 --- /dev/null +++ b/src/utils/jwt.rs @@ -0,0 +1,58 @@ +use crate::utils::keys::get_keys; +use common_telemetry::AppError; +use jsonwebtoken::{Algorithm, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // User ID + pub tenant_id: String, // Tenant ID + pub exp: usize, // Expiration + pub iat: usize, // Issued At + pub iss: String, // Issuer + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub permissions: Vec, +} + +pub fn sign( + user_id: Uuid, + tenant_id: Uuid, + roles: Vec, + permissions: Vec, +) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize; + + let expiration = now + 15 * 60; // 15 minutes access token + + let claims = Claims { + sub: user_id.to_string(), + tenant_id: tenant_id.to_string(), + exp: expiration, + iat: now, + iss: "iam-service".to_string(), + roles, + permissions, + }; + + let keys = get_keys(); + encode(&Header::new(Algorithm::RS256), &claims, &keys.encoding_key) + .map_err(|e| AppError::AuthError(e.to_string())) +} + +pub fn verify(token: &str) -> Result { + let keys = get_keys(); + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&["iam-service"]); + + let token_data = decode::(token, &keys.decoding_key, &validation) + .map_err(|e| AppError::AuthError(e.to_string()))?; + + Ok(token_data.claims) +} diff --git a/src/utils/keys.rs b/src/utils/keys.rs new file mode 100644 index 0000000..0c5789a --- /dev/null +++ b/src/utils/keys.rs @@ -0,0 +1,40 @@ +use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey}; +use rsa::rand_core::OsRng; +use rsa::{RsaPrivateKey, RsaPublicKey, pkcs1::LineEnding}; +use std::sync::OnceLock; + +pub struct KeyPair { + pub encoding_key: jsonwebtoken::EncodingKey, + pub decoding_key: jsonwebtoken::DecodingKey, +} + +static KEYS: OnceLock = OnceLock::new(); + +pub fn get_keys() -> &'static KeyPair { + KEYS.get_or_init(|| { + // In a real production app, you would load these from files or ENV variables + // defined in your AppConfig. + // For now, we generate a fresh key pair on startup. + + let bits = 2048; + let private_key = RsaPrivateKey::new(&mut OsRng, bits).expect("failed to generate a key"); + let public_key = RsaPublicKey::from(&private_key); + + let private_pem = private_key + .to_pkcs1_pem(LineEnding::LF) + .expect("failed to encode private key"); + let public_pem = public_key + .to_pkcs1_pem(LineEnding::LF) + .expect("failed to encode public key"); + + let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes()) + .expect("failed to create encoding key"); + let decoding_key = jsonwebtoken::DecodingKey::from_rsa_pem(public_pem.as_bytes()) + .expect("failed to create decoding key"); + + KeyPair { + encoding_key, + decoding_key, + } + }) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 64f2aee..8271a98 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,60 +1,6 @@ -use argon2::{ - Argon2, - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, -}; -use jsonwebtoken::{EncodingKey, Header, encode}; -use serde::{Deserialize, Serialize}; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; +pub mod keys; +pub mod jwt; +pub mod password; -// --- 密码部分 --- - -pub fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - let password_hash = argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| e.to_string())? - .to_string(); - Ok(password_hash) -} - -pub fn verify_password(password: &str, password_hash: &str) -> bool { - let parsed_hash = match PasswordHash::new(password_hash) { - Ok(h) => h, - Err(_) => return false, - }; - Argon2::default() - .verify_password(password.as_bytes(), &parsed_hash) - .is_ok() -} - -// --- JWT 部分 --- - -#[derive(Debug, Serialize, Deserialize)] -pub struct Claims { - pub sub: String, // 用户ID - pub tenant_id: String, // 租户ID (关键!) - pub exp: usize, // 过期时间 -} - -pub fn create_jwt(user_id: Uuid, tenant_id: Uuid, secret: &str) -> Result { - let expiration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as usize - + 24 * 3600; // 24小时过期 - - let claims = Claims { - sub: user_id.to_string(), - tenant_id: tenant_id.to_string(), - exp: expiration, - }; - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(secret.as_ref()), - ) - .map_err(|e| e.to_string()) -} +pub use password::{hash_password, verify_password}; +pub use jwt::{sign, verify}; diff --git a/src/utils/password.rs b/src/utils/password.rs new file mode 100644 index 0000000..7803530 --- /dev/null +++ b/src/utils/password.rs @@ -0,0 +1,24 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| e.to_string())? + .to_string(); + Ok(password_hash) +} + +pub fn verify_password(password: &str, password_hash: &str) -> bool { + let parsed_hash = match PasswordHash::new(password_hash) { + Ok(h) => h, + Err(_) => return false, + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok() +} diff --git a/tests/db_smoke.rs b/tests/db_smoke.rs new file mode 100644 index 0000000..f5082e3 --- /dev/null +++ b/tests/db_smoke.rs @@ -0,0 +1,47 @@ +use sqlx::PgPool; + +#[tokio::test] +async fn db_smoke_tenants_users_roundtrip() -> Result<(), Box> { + let database_url = match std::env::var("DATABASE_URL") { + Ok(v) if !v.trim().is_empty() => v, + _ => return Ok(()), + }; + + let pool = PgPool::connect(&database_url).await?; + let mut tx = pool.begin().await?; + + let tenant_id: uuid::Uuid = sqlx::query_scalar( + r#" + INSERT INTO tenants (name, status, config) + VALUES ($1, 'active', '{}'::jsonb) + RETURNING id + "#, + ) + .bind(format!("smoke-{}", uuid::Uuid::new_v4())) + .fetch_one(&mut *tx) + .await?; + + let user_id: uuid::Uuid = sqlx::query_scalar( + r#" + INSERT INTO users (tenant_id, email, password_hash) + VALUES ($1, $2, $3) + RETURNING id + "#, + ) + .bind(tenant_id) + .bind(format!("smoke-{}@example.com", uuid::Uuid::new_v4())) + .bind("hash") + .fetch_one(&mut *tx) + .await?; + + let found: i64 = sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1 AND id = $2") + .bind(tenant_id) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + assert_eq!(found, 1); + tx.rollback().await?; + Ok(()) +} +