fix(handlers): add handlers
This commit is contained in:
369
Cargo.lock
generated
369
Cargo.lock
generated
@@ -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"
|
||||
|
||||
61
Cargo.toml
61
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"
|
||||
|
||||
123
README.md
123
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: <uuid>` 传入租户上下文,并在 Service/SQL 查询中使用 `tenant_id` 过滤
|
||||
- HTTP 层优先通过 `Authorization: Bearer <access_token>` 中的 `tenant_id` claim 传入租户上下文,并在 Service/SQL 查询中使用 `tenant_id` 过滤
|
||||
- 兼容:仍支持请求头 `X-Tenant-ID: <uuid>`,若同时提供 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 <your-repo-url>
|
||||
cd iam-service
|
||||
```
|
||||
|
||||
2. 初始化数据库(示例)
|
||||
2. 初始化数据库(开发/测试示例)
|
||||
|
||||
```bash
|
||||
psql -h <pg_host> -U <admin_user> -f init.sql
|
||||
export DATABASE_URL='postgres://iam_service_user:***@<pg_host>: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": "<jwt>",
|
||||
"refresh_token": "<opaque>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900
|
||||
},
|
||||
"trace_id": null
|
||||
}
|
||||
```
|
||||
|
||||
- `X-Tenant-ID: <tenant_uuid>`
|
||||
### 租户信息维护(需要鉴权 + 权限)
|
||||
|
||||
建议在后续迭代中:
|
||||
- `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 <access_token>"
|
||||
```
|
||||
|
||||
## 权限控制(当前实现方式)
|
||||
|
||||
- 认证(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 伪造绕过。
|
||||
|
||||
## 部署指南
|
||||
|
||||
|
||||
87
docs/AUTHZ_DESIGN.md
Normal file
87
docs/AUTHZ_DESIGN.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 权限控制架构设计(Tenant-User-Role)
|
||||
|
||||
## 目标
|
||||
|
||||
- 保证租户隔离:任何用户只能访问其 Token 绑定的租户数据
|
||||
- 保证授权一致:同一类请求在所有入口都遵循同样的鉴权与授权规则
|
||||
- 保证可演进:RBAC 可以稳定运行,ABAC 可逐步引入策略引擎与审计闭环
|
||||
|
||||
## 分层职责与落点
|
||||
|
||||
### 认证(Authentication):中间件层
|
||||
|
||||
- 位置:`src/middleware/auth.rs`
|
||||
- 输入:`Authorization: Bearer <access_token>`
|
||||
- 输出:解析/验签 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 作为数据库兜底隔离层。
|
||||
|
||||
90
docs/DB_PROVISIONING.md
Normal file
90
docs/DB_PROVISIONING.md
Normal file
@@ -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
|
||||
```
|
||||
155
docs/SCALAR_GUIDE.md
Normal file
155
docs/SCALAR_GUIDE.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# IAM Service — Scalar 调用顺序指南
|
||||
|
||||
## Authentication(认证方式)
|
||||
|
||||
本服务使用 **JWT Bearer Token**:
|
||||
|
||||
- 登录成功后拿到 `access_token`
|
||||
- 后续请求在 Header 中带:
|
||||
- `Authorization: Bearer <access_token>`
|
||||
- 租户上下文:
|
||||
- 保护接口默认从 Token claim 的 `tenant_id` 推导租户
|
||||
- 可选兼容 `X-Tenant-ID: <uuid>`,若同时提供 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": "<tenant_id>", "name": "Tenant A", "status": "active", "config": {} }, "trace_id": null }
|
||||
```
|
||||
|
||||
下一步依赖:`tenant_id`(用于注册/登录时的 `X-Tenant-ID`)。
|
||||
|
||||
### Step 1:注册用户
|
||||
|
||||
**POST** `/auth/register`
|
||||
|
||||
- 必需 Header:`X-Tenant-ID: <tenant_id>`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "securePassword123" }
|
||||
```
|
||||
|
||||
成功(201)从 `data.id` 取出 `user_id`(后续可用于用户管理接口):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Created", "data": { "id": "<user_id>", "email": "user@example.com" }, "trace_id": null }
|
||||
```
|
||||
|
||||
### Step 2:登录获取访问令牌(Authentication 入口)
|
||||
|
||||
**POST** `/auth/login`
|
||||
|
||||
- 必需 Header:`X-Tenant-ID: <tenant_id>`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "securePassword123" }
|
||||
```
|
||||
|
||||
成功(200)从 `data.access_token` 取出访问令牌:
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Success", "data": { "access_token": "<jwt>", "refresh_token": "<opaque>", "token_type": "Bearer", "expires_in": 900 }, "trace_id": null }
|
||||
```
|
||||
|
||||
下一步依赖:`access_token`。
|
||||
|
||||
### Step 3:获取当前租户信息(Tenant)
|
||||
|
||||
**GET** `/tenants/me`
|
||||
|
||||
- 必需 Header:`Authorization: Bearer <access_token>`
|
||||
- 可选 Header:`X-Tenant-ID: <tenant_id>`(如提供必须与 token tenant_id 一致)
|
||||
|
||||
成功(200):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Success", "data": { "id": "<tenant_id>", "name": "Tenant A", "status": "active", "config": {} }, "trace_id": null }
|
||||
```
|
||||
|
||||
### Step 4:查看当前用户权限(Me)
|
||||
|
||||
**GET** `/me/permissions`
|
||||
|
||||
- 必需 Header:`Authorization: Bearer <access_token>`
|
||||
|
||||
成功(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 <access_token>`
|
||||
- 分页规则:
|
||||
- `page` 默认 1,必须 >= 1
|
||||
- `page_size` 默认 20,范围 1..=200
|
||||
|
||||
成功(200):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Success", "data": [{ "id": "<user_id>", "email": "user@example.com" }], "trace_id": null }
|
||||
```
|
||||
|
||||
### Step 6:列出角色(Role)
|
||||
|
||||
**GET** `/roles`
|
||||
|
||||
- 必需 Header:`Authorization: Bearer <access_token>`
|
||||
|
||||
成功(200):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Success", "data": [{ "id": "<role_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`
|
||||
|
||||
23
docs/TEMP.md
Normal file
23
docs/TEMP.md
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
104
docs/TENANT_API.md
Normal file
104
docs/TENANT_API.md
Normal file
@@ -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 <access_token>`
|
||||
- 租户上下文优先来自 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 <access_token>`
|
||||
- `X-Tenant-ID: <uuid>`(可选)
|
||||
|
||||
### 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`
|
||||
|
||||
说明:当前实现为物理删除。生产建议改为软删除并触发异步数据清理流程。
|
||||
|
||||
36
init.sql
36
init.sql
@@ -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');
|
||||
89
scripts/db/rebuild_iam_db.sh
Executable file
89
scripts/db/rebuild_iam_db.sh
Executable file
@@ -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"
|
||||
11
sql/drop_iam_schema.sql
Normal file
11
sql/drop_iam_schema.sql
Normal file
@@ -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;
|
||||
|
||||
106
sql/schema_post_init.sql
Normal file
106
sql/schema_post_init.sql
Normal file
@@ -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';
|
||||
|
||||
74
sql/verify_iam_schema.sql
Normal file
74
sql/verify_iam_schema.sql
Normal file
@@ -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 $$;
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PgPool, sqlx::Error> {
|
||||
pub async fn init_pool(config: &AppConfig) -> Result<PgPool, sqlx::Error> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
105
src/docs.rs
Normal file
105
src/docs.rs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
67
src/handlers/auth.rs
Normal file
67
src/handlers/auth.rs
Normal file
@@ -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<AppState>,
|
||||
// 3. 获取 Body
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> Result<AppResponse<UserResponse>, 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<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<AppResponse<LoginResponse>, AppError> {
|
||||
let response = state.auth_service.login(tenant_id, payload).await?;
|
||||
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
59
src/handlers/authorization.rs
Normal file
59
src/handlers/authorization.rs
Normal file
@@ -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 <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 查询当前登录用户在当前租户下的权限编码列表。
|
||||
///
|
||||
/// 用途:
|
||||
/// - 快速自查当前令牌是否携带期望的权限(便于联调与排障)。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - 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<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
) -> Result<AppResponse<Vec<String>>, 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))
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
// 3. 获取 Body
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> Result<Json<UserResponse>, 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,
|
||||
}
|
||||
|
||||
132
src/handlers/role.rs
Normal file
132
src/handlers/role.rs
Normal file
@@ -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 <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 在当前租户下创建角色。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 角色归属到当前租户(由 `TenantId` 决定),禁止跨租户写入。
|
||||
/// - 需要具备 `role:write` 权限,否则返回 403。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Body `CreateRoleRequest`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `201`:返回新建角色信息(含 `id`)
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `400`:请求参数错误
|
||||
pub async fn create_role_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Json(payload): Json<CreateRoleRequest>,
|
||||
) -> Result<AppResponse<RoleResponse>, 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 <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 查询当前租户下的角色列表。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅返回当前租户角色;若 `X-Tenant-ID` 与 Token 不一致则返回 403。
|
||||
/// - 需要具备 `role:read` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:角色列表
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn list_roles_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
) -> Result<AppResponse<Vec<RoleResponse>>, 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))
|
||||
}
|
||||
294
src/handlers/tenant.rs
Normal file
294
src/handlers/tenant.rs
Normal file
@@ -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<AppState>,
|
||||
Json(payload): Json<CreateTenantRequest>,
|
||||
) -> Result<AppResponse<TenantResponse>, 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 <access_token>(访问令牌)"),
|
||||
("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 <access_token>`(必填)
|
||||
/// - Header `X-Tenant-ID`(可选;若提供需与 Token 一致)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:租户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn get_tenant_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
) -> Result<AppResponse<TenantResponse>, 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 <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 更新当前租户的基础信息(名称 / 配置)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 只允许更新当前登录租户;租户不一致返回 403。
|
||||
/// - 需要具备 `tenant:write` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Body `UpdateTenantRequest`:`name` / `config` 为可选字段,未提供则保持不变
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:更新后的租户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn update_tenant_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Json(payload): Json<UpdateTenantRequest>,
|
||||
) -> Result<AppResponse<TenantResponse>, 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 <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
/// 更新当前租户状态(如 active / disabled)。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 只允许更新当前登录租户;租户不一致返回 403。
|
||||
/// - 需要具备 `tenant:write` 权限。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Body `UpdateTenantStatusRequest.status`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:更新后的租户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn update_tenant_status_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Json(payload): Json<UpdateTenantStatusRequest>,
|
||||
) -> Result<AppResponse<TenantResponse>, 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 <access_token>(访问令牌)"),
|
||||
("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<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
) -> Result<AppResponse<()>, 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())
|
||||
}
|
||||
293
src/handlers/user.rs
Normal file
293
src/handlers/user.rs
Normal file
@@ -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<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
#[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 <access_token>(访问令牌)"),
|
||||
("X-Tenant-ID" = String, Header, description = "租户 UUID(可选,若提供需与 Token 中 tenant_id 一致)"),
|
||||
("page" = Option<u32>, Query, description = "页码,默认 1"),
|
||||
("page_size" = Option<u32>, Query, description = "每页数量,默认 20,最大 200")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
/// 分页查询当前租户下的用户列表。
|
||||
///
|
||||
/// 业务规则:
|
||||
/// - 仅返回当前租户用户;租户不一致返回 403。
|
||||
/// - 需要具备 `user:read` 权限。
|
||||
/// - 分页参数约束:`page>=1`,`page_size` 范围 `1..=200`。
|
||||
///
|
||||
/// 输入:
|
||||
/// - Header `Authorization: Bearer <access_token>`(必填)
|
||||
/// - Query `page` / `page_size`(可选)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:用户列表
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:分页参数非法
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
pub async fn list_users_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Query(query): Query<ListUsersQuery>,
|
||||
) -> Result<AppResponse<Vec<UserResponse>>, 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 <access_token>(访问令牌)"),
|
||||
("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 <access_token>`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:用户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:用户不存在
|
||||
pub async fn get_user_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Path(target_user_id): Path<Uuid>,
|
||||
) -> Result<AppResponse<UserResponse>, 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 <access_token>(访问令牌)"),
|
||||
("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 <access_token>`(必填)
|
||||
/// - Body `UpdateUserRequest`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:更新后的用户信息
|
||||
///
|
||||
/// 异常:
|
||||
/// - `400`:请求参数错误
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:用户不存在
|
||||
pub async fn update_user_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Path(target_user_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateUserRequest>,
|
||||
) -> Result<AppResponse<UserResponse>, 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 <access_token>(访问令牌)"),
|
||||
("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 <access_token>`(必填)
|
||||
///
|
||||
/// 输出:
|
||||
/// - `200`:删除成功(空响应)
|
||||
///
|
||||
/// 异常:
|
||||
/// - `401`:未认证
|
||||
/// - `403`:租户不匹配或无权限
|
||||
/// - `404`:用户不存在
|
||||
pub async fn delete_user_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
AuthContext {
|
||||
tenant_id: auth_tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: AuthContext,
|
||||
Path(target_user_id): Path<Uuid>,
|
||||
) -> Result<AppResponse<()>, 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())
|
||||
}
|
||||
108
src/main.rs
108
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::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
67
src/middleware/auth.rs
Normal file
67
src/middleware/auth.rs
Normal file
@@ -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<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn authenticate(mut req: Request, next: Next) -> Result<Response, AppError> {
|
||||
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<S> FromRequestParts<S> for AuthContext
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<AuthContext>()
|
||||
.cloned()
|
||||
.ok_or(AppError::MissingAuthHeader)
|
||||
}
|
||||
}
|
||||
@@ -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<Response, StatusCode> {
|
||||
pub async fn resolve_tenant(mut req: Request, next: Next) -> Result<Response, AppError> {
|
||||
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::<auth::AuthContext>()
|
||||
.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<Response, St
|
||||
match tenant_id_str {
|
||||
Some(id_str) => {
|
||||
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<S> FromRequestParts<S> for TenantId
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
if let Some(tid) = parts.extensions.get::<TenantId>().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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
369
src/middleware/rate_limit.rs
Normal file
369
src/middleware/rate_limit.rs
Normal file
@@ -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<IpNet>,
|
||||
}
|
||||
|
||||
impl TrustedProxySmartIpKeyExtractor {
|
||||
fn from_env() -> Self {
|
||||
static TRUSTED: OnceLock<Vec<IpNet>> = 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::<IpNet>()
|
||||
.unwrap_or_else(|_| panic!("Invalid TRUSTED_PROXY_CIDRS entry: {s}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.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<T>(&self, req: &http::Request<T>) -> Result<Self::Key, GovernorError> {
|
||||
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<http::HeaderMap> {
|
||||
match err {
|
||||
GovernorError::TooManyRequests { headers, .. } => headers.clone(),
|
||||
GovernorError::Other { headers, .. } => headers.clone(),
|
||||
GovernorError::UnableToExtractKey => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn governor_wait_time_seconds(err: &GovernorError) -> Option<u64> {
|
||||
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<u64> {
|
||||
resp.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn ip_for_log(req: &Request) -> Option<IpAddr> {
|
||||
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::<crate::middleware::auth::AuthContext>()
|
||||
.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<TrustedProxySmartIpKeyExtractor, StateInformationMiddleware, axum::body::Body> {
|
||||
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<TrustedProxySmartIpKeyExtractor, StateInformationMiddleware, axum::body::Body> {
|
||||
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<Mutex<Vec<u8>>>);
|
||||
|
||||
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<Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for BufferGuard {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
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::<u8>::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\""));
|
||||
}
|
||||
}
|
||||
190
src/models.rs
190
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<String>,
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct CreateRoleRequest {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
// --- 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<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct UpdateTenantRequest {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub config: Option<Value>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
222
src/services/auth.rs
Normal file
222
src/services/auth.rs
Normal file
@@ -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<User, AppError> {
|
||||
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<LoginResponse, AppError> {
|
||||
// 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(())
|
||||
}
|
||||
}
|
||||
79
src/services/authorization.rs
Normal file
79
src/services/authorization.rs
Normal file
@@ -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<Vec<String>, 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Json<User>, 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<String, String> {
|
||||
// 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;
|
||||
|
||||
59
src/services/role.rs
Normal file
59
src/services/role.rs
Normal file
@@ -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<Role, AppError> {
|
||||
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<Vec<Role>, 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))
|
||||
}
|
||||
}
|
||||
134
src/services/tenant.rs
Normal file
134
src/services/tenant.rs
Normal file
@@ -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<Tenant, AppError> {
|
||||
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<Tenant, AppError> {
|
||||
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<Tenant, AppError> {
|
||||
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<Tenant, AppError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
109
src/services/user.rs
Normal file
109
src/services/user.rs
Normal file
@@ -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<User, AppError> {
|
||||
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<Vec<User>, 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<User, AppError> {
|
||||
// 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(())
|
||||
}
|
||||
}
|
||||
58
src/utils/jwt.rs
Normal file
58
src/utils/jwt.rs
Normal file
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn sign(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
roles: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
) -> Result<String, AppError> {
|
||||
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<Claims, AppError> {
|
||||
let keys = get_keys();
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_issuer(&["iam-service"]);
|
||||
|
||||
let token_data = decode::<Claims>(token, &keys.decoding_key, &validation)
|
||||
.map_err(|e| AppError::AuthError(e.to_string()))?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
40
src/utils/keys.rs
Normal file
40
src/utils/keys.rs
Normal file
@@ -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<KeyPair> = 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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};
|
||||
|
||||
24
src/utils/password.rs
Normal file
24
src/utils/password.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String, String> {
|
||||
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()
|
||||
}
|
||||
47
tests/db_smoke.rs
Normal file
47
tests/db_smoke.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[tokio::test]
|
||||
async fn db_smoke_tenants_users_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user