P0: tc-auth local sessions, tc-api P0 endpoints, teamclaw-server binary
- tc-auth: argon2id passwords, hashed opaque bearer tokens in auth_sessions
(migration 0002), anti-enumeration login errors, redacted token Debug
- tc-api: axum router with /healthz, /api/auth/login|logout, /api/user/me,
/api/team/{claws,credits,permissions}; Authed bearer extractor + RBAC
permission derivation; integration-tested over real TCP vs real Postgres
- teamclaw-server: config -> pool -> self-migrate -> serve
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0afb359183
commit
c4349bf292
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM auth_sessions WHERE token_hash = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "47831a76c636349a39ae29d4feefd1e70d83dbc08a424d0d5f49fa666ec070e1"
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, password_hash FROM users WHERE email = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4cd14ef0a86a8e666d0c7482f5b7448633610dc688c1f7d3ade9efb403201c41"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE users SET password_hash = $2 WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "4d4d46a946f0083e2dd5037ffba55c3ea33db13d224b3cf1f8bc8cefb26cc283"
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT u.id, u.workspace_id, u.role\n FROM auth_sessions s\n JOIN users u ON u.id = s.user_id\n WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "workspace_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "role",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "900827c5c8c24f4861120e98e3cc8a5b70f22e9f4b4168c9e8eb51c53d68bdae"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO auth_sessions (token_hash, user_id, expires_at)\n VALUES ($1, $2, $3)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Timestamptz"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e04949b5ecf5e8085624656df4dc378163cb6a9354402dcfbf7add44e060acc8"
|
||||||
|
}
|
||||||
Generated
+259
@@ -32,6 +32,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "astral-tokio-tar"
|
name = "astral-tokio-tar"
|
||||||
version = "0.5.6"
|
version = "0.5.6"
|
||||||
@@ -119,10 +131,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"itoa",
|
"itoa",
|
||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -130,10 +145,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -152,6 +172,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -203,6 +224,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -338,6 +368,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -775,8 +811,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -786,9 +824,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -989,6 +1029,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1010,13 +1051,16 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
"libc",
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1207,6 +1251,12 @@ version = "0.1.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1309,6 +1359,12 @@ version = "0.4.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -1510,6 +1566,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pear"
|
name = "pear"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1726,6 +1793,61 @@ version = "1.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1882,6 +2004,44 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -1977,6 +2137,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2126,6 +2287,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2542,6 +2714,9 @@ name = "sync_wrapper"
|
|||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
@@ -2554,6 +2729,41 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tc-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tc-auth",
|
||||||
|
"tc-db",
|
||||||
|
"tc-domain",
|
||||||
|
"tc-testkit",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tc-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"base64",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"tc-db",
|
||||||
|
"tc-domain",
|
||||||
|
"tc-testkit",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tc-config"
|
name = "tc-config"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2600,6 +2810,17 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "teamclaw-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"tc-api",
|
||||||
|
"tc-config",
|
||||||
|
"tc-db",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -2887,6 +3108,24 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-layer"
|
name = "tower-layer"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -3144,6 +3383,16 @@ dependencies = [
|
|||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.73"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.123"
|
version = "0.2.123"
|
||||||
@@ -3210,6 +3459,16 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-time"
|
name = "web-time"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ members = [
|
|||||||
"crates/tc-config",
|
"crates/tc-config",
|
||||||
"crates/tc-db",
|
"crates/tc-db",
|
||||||
"crates/tc-testkit",
|
"crates/tc-testkit",
|
||||||
|
"crates/tc-auth",
|
||||||
|
"crates/tc-api",
|
||||||
|
"crates/bins/teamclaw-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "teamclaw-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8"
|
||||||
|
tc-api = { path = "../../tc-api" }
|
||||||
|
tc-config = { path = "../../tc-config" }
|
||||||
|
tc-db = { path = "../../tc-db" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! TeamClaw server: REST API, and (in later phases) the streaming gateway,
|
||||||
|
//! scheduler, and safety worker, composed into one binary.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use tc_config::AppConfig;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
match run().await {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("teamclaw-server: {message}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), String> {
|
||||||
|
let config_path =
|
||||||
|
PathBuf::from(std::env::var("TEAMCLAW_CONFIG").unwrap_or_else(|_| "teamclaw.toml".into()));
|
||||||
|
let config = AppConfig::load_from(&config_path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let pool = tc_db::connect(&config.database.url, config.database.max_connections)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("database connection failed: {e}"))?;
|
||||||
|
tc_db::MIGRATOR
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("migrations failed: {e}"))?;
|
||||||
|
|
||||||
|
let app = tc_api::router(tc_api::AppState::new(pool));
|
||||||
|
let listener = tokio::net::TcpListener::bind(config.listen_addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("bind {} failed: {e}", config.listen_addr))?;
|
||||||
|
println!("teamclaw-server listening on {}", config.listen_addr);
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("server error: {e}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
tc-auth = { path = "../tc-auth" }
|
||||||
|
tc-db = { path = "../tc-db" }
|
||||||
|
tc-domain = { path = "../tc-domain" }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
time = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
tc-testkit = { path = "../tc-testkit" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// API-surface errors with their HTTP mapping. Internal causes are logged
|
||||||
|
/// server-side, never echoed to clients.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ApiError {
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("internal error")]
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tc_db::DbError> for ApiError {
|
||||||
|
fn from(err: tc_db::DbError) -> Self {
|
||||||
|
match err {
|
||||||
|
tc_db::DbError::NotFound => ApiError::NotFound,
|
||||||
|
_ => ApiError::Internal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tc_auth::AuthError> for ApiError {
|
||||||
|
fn from(err: tc_auth::AuthError) -> Self {
|
||||||
|
match err {
|
||||||
|
tc_auth::AuthError::InvalidCredentials | tc_auth::AuthError::Unauthenticated => {
|
||||||
|
ApiError::Unauthorized
|
||||||
|
}
|
||||||
|
_ => ApiError::Internal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = match self {
|
||||||
|
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
ApiError::Forbidden => StatusCode::FORBIDDEN,
|
||||||
|
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": self.to_string() }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::header::AUTHORIZATION;
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
use tc_auth::AuthedUser;
|
||||||
|
|
||||||
|
use crate::{ApiError, AppState};
|
||||||
|
|
||||||
|
/// Extracts and verifies the bearer token on protected routes.
|
||||||
|
pub struct Authed(pub AuthedUser);
|
||||||
|
|
||||||
|
/// Pulls the raw bearer secret out of the Authorization header.
|
||||||
|
pub(crate) fn bearer_secret(parts: &Parts) -> Result<&str, ApiError> {
|
||||||
|
parts
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.ok_or(ApiError::Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for Authed {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let secret = bearer_secret(parts)?;
|
||||||
|
let user = state.auth.authenticate(secret).await?;
|
||||||
|
Ok(Authed(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! REST API for TeamClaw (spec §13). One route resource per module.
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod extract;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_auth::AuthService;
|
||||||
|
|
||||||
|
pub use error::ApiError;
|
||||||
|
pub use extract::Authed;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub auth: AuthService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(pool: PgPool) -> AppState {
|
||||||
|
let auth = AuthService::new(pool.clone());
|
||||||
|
AppState { pool, auth }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/healthz", get(routes::health::healthz))
|
||||||
|
.route("/api/auth/login", post(routes::auth::login))
|
||||||
|
.route("/api/auth/logout", post(routes::auth::logout))
|
||||||
|
.route("/api/user/me", get(routes::identity::me))
|
||||||
|
.route("/api/team/claws", get(routes::team::claws))
|
||||||
|
.route("/api/team/credits", get(routes::team::credits))
|
||||||
|
.route("/api/team/permissions", get(routes::team::permissions))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::extract::bearer_secret;
|
||||||
|
use crate::{ApiError, AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let token = state.auth.login_local(&body.email, &body.password).await?;
|
||||||
|
Ok(Json(json!({ "token": token.secret() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes the presented token. No prior authentication step: deleting by
|
||||||
|
/// token hash can only ever revoke the session of the token the caller
|
||||||
|
/// already holds, and revoking an expired session must still succeed.
|
||||||
|
pub async fn logout(State(state): State<AppState>, req: Request) -> Result<StatusCode, ApiError> {
|
||||||
|
let (parts, _) = req.into_parts();
|
||||||
|
let secret = bearer_secret(&parts)?;
|
||||||
|
state.auth.logout(secret).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
/// Liveness probe; deliberately unauthenticated so orchestrators can use it.
|
||||||
|
pub async fn healthz() -> StatusCode {
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use tc_domain::User;
|
||||||
|
|
||||||
|
use crate::{ApiError, AppState, Authed};
|
||||||
|
|
||||||
|
pub async fn me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<User>, ApiError> {
|
||||||
|
let me = tc_db::repo::users::get(&state.pool, user.user_id).await?;
|
||||||
|
Ok(Json(me))
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod health;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod team;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tc_domain::Agent;
|
||||||
|
|
||||||
|
use crate::{ApiError, AppState, Authed};
|
||||||
|
|
||||||
|
/// The left-rail agent roster (§4).
|
||||||
|
pub async fn claws(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<Vec<Agent>>, ApiError> {
|
||||||
|
let roster = tc_db::repo::agents::roster(&state.pool, user.workspace_id).await?;
|
||||||
|
Ok(Json(roster))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Available credit balance for the Credits page (§8.4).
|
||||||
|
pub async fn credits(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let available = tc_db::repo::credits::balance(&state.pool, user.workspace_id).await?;
|
||||||
|
Ok(Json(json!({ "available": available })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the caller may do, derived from their role (§16 RBAC).
|
||||||
|
pub async fn permissions(Authed(user): Authed) -> Json<Value> {
|
||||||
|
let owner = user.role.is_owner();
|
||||||
|
Json(json!({
|
||||||
|
"role": user.role,
|
||||||
|
"can_manage_team": owner,
|
||||||
|
"can_manage_billing": owner,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
use serde_json::{json, Value};
|
||||||
|
use tc_api::AppState;
|
||||||
|
use tc_auth::AuthService;
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TestServer {
|
||||||
|
base: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boots the real axum server on an ephemeral port over real TCP.
|
||||||
|
async fn serve(pool: sqlx::PgPool) -> TestServer {
|
||||||
|
let state = AppState::new(pool);
|
||||||
|
let app = tc_api::router(state);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
TestServer {
|
||||||
|
base: format!("http://{addr}"),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed(pool: &sqlx::PgPool) -> (Workspace, User, Agent) {
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: "[email protected]".into(),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(pool, &owner).await.unwrap();
|
||||||
|
let agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Research Analyst".into(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
avatar: "scout-1".into(),
|
||||||
|
accent: "#f96565".into(),
|
||||||
|
wallpaper: "dunes".into(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Online,
|
||||||
|
};
|
||||||
|
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
AuthService::new(pool.clone())
|
||||||
|
.set_password(owner.id, "pw")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(ws, owner, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(server: &TestServer) -> String {
|
||||||
|
let res = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/login", server.base))
|
||||||
|
.json(&json!({"email": "[email protected]", "password": "pw"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 200);
|
||||||
|
res.json::<Value>().await.unwrap()["token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn healthz_needs_no_auth() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let res = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/healthz", server.base))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn protected_routes_reject_missing_or_bad_tokens() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
for path in ["/api/user/me", "/api/team/claws", "/api/team/credits"] {
|
||||||
|
let bare = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}{path}", server.base))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bare.status(), 401, "{path} without token");
|
||||||
|
let bad = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}{path}", server.base))
|
||||||
|
.bearer_auth("forged-token")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bad.status(), 401, "{path} with forged token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_rejects_wrong_password() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let res = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/login", server.base))
|
||||||
|
.json(&json!({"email": "[email protected]", "password": "nope"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn user_me_returns_the_authenticated_user() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (ws, owner, _) = seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let me: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/user/me", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(me["id"], owner.id.to_string());
|
||||||
|
assert_eq!(me["workspace_id"], ws.id.to_string());
|
||||||
|
assert_eq!(me["email"], "[email protected]");
|
||||||
|
assert_eq!(me["role"], "owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn team_claws_lists_the_roster() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, _, agent) = seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let claws: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/team/claws", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let list = claws.as_array().unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0]["id"], agent.id.to_string());
|
||||||
|
assert_eq!(list[0]["name"], "Scout");
|
||||||
|
assert_eq!(list[0]["status"], "online");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn team_credits_returns_available_balance() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (ws, _, _) = seed(&pool).await;
|
||||||
|
tc_db::repo::credits::add_lot(&pool, ws.id, 500, "purchase")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let credits: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/team/credits", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(credits["available"], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn team_permissions_reflect_role() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let perms: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/team/permissions", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(perms["role"], "owner");
|
||||||
|
assert_eq!(perms["can_manage_team"], true);
|
||||||
|
assert_eq!(perms["can_manage_billing"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logout_revokes_the_session() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let res = server
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/logout", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 204);
|
||||||
|
|
||||||
|
let me = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/user/me", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(me.status(), 401);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "tc-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
argon2 = "0.5"
|
||||||
|
base64 = "0.22"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
tc-db = { path = "../tc-db" }
|
||||||
|
tc-domain = { path = "../tc-domain" }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
time = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tc-testkit = { path = "../tc-testkit" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! Authentication for TeamClaw (spec §16: SSO + RBAC).
|
||||||
|
//!
|
||||||
|
//! `local` mode: argon2id password hashes and opaque bearer tokens whose
|
||||||
|
//! SHA-256 hashes are stored in `auth_sessions`. This is the zero-dependency
|
||||||
|
//! appliance mode for air-gapped installs; OIDC SSO shares the same session
|
||||||
|
//! storage and `AuthedUser` output.
|
||||||
|
|
||||||
|
mod service;
|
||||||
|
mod token;
|
||||||
|
|
||||||
|
pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL};
|
||||||
|
pub use token::SessionToken;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use argon2::password_hash::rand_core::OsRng;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tc_domain::{Role, UserId, WorkspaceId};
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
use crate::token::{hash_token, SessionToken};
|
||||||
|
|
||||||
|
/// How long a login session stays valid.
|
||||||
|
pub const SESSION_TTL: Duration = Duration::days(7);
|
||||||
|
|
||||||
|
/// The authenticated caller attached to every API request: everything RBAC
|
||||||
|
/// decisions need, nothing more.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct AuthedUser {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub workspace_id: WorkspaceId,
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
/// Wrong password and unknown email are deliberately the same error so
|
||||||
|
/// login cannot be used to enumerate accounts.
|
||||||
|
#[error("invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
|
#[error("unauthenticated")]
|
||||||
|
Unauthenticated,
|
||||||
|
#[error("password hashing failed: {0}")]
|
||||||
|
Hashing(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthService {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
pub fn new(pool: PgPool) -> AuthService {
|
||||||
|
AuthService { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets (or resets) a user's local password.
|
||||||
|
pub async fn set_password(&self, user_id: UserId, password: &str) -> Result<(), AuthError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AuthError::Hashing(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET password_hash = $2 WHERE id = $1",
|
||||||
|
user_id.as_uuid(),
|
||||||
|
hash,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies email + password and opens a new session.
|
||||||
|
pub async fn login_local(
|
||||||
|
&self,
|
||||||
|
email: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<SessionToken, AuthError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, password_hash FROM users WHERE email = $1",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let (user_id, stored_hash) = match row {
|
||||||
|
Some(row) => match row.password_hash {
|
||||||
|
Some(hash) => (row.id, hash),
|
||||||
|
None => return Err(AuthError::InvalidCredentials),
|
||||||
|
},
|
||||||
|
None => return Err(AuthError::InvalidCredentials),
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
PasswordHash::new(&stored_hash).map_err(|e| AuthError::Hashing(e.to_string()))?;
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.map_err(|_| AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
let token = SessionToken::generate();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO auth_sessions (token_hash, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
hash_token(token.secret()),
|
||||||
|
user_id,
|
||||||
|
OffsetDateTime::now_utc() + SESSION_TTL,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a bearer token to the authenticated user.
|
||||||
|
pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT u.id, u.workspace_id, u.role
|
||||||
|
FROM auth_sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||||
|
hash_token(token_secret),
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AuthError::Unauthenticated)?;
|
||||||
|
Ok(AuthedUser {
|
||||||
|
user_id: UserId::from(row.id),
|
||||||
|
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||||
|
role: if row.role == "owner" {
|
||||||
|
Role::Owner
|
||||||
|
} else {
|
||||||
|
Role::Member
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the session for this token.
|
||||||
|
pub async fn logout(&self, token_secret: &str) -> Result<(), AuthError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM auth_sessions WHERE token_hash = $1",
|
||||||
|
hash_token(token_secret),
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine;
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// An opaque bearer token handed to the client exactly once at login.
|
||||||
|
/// Only its hash is persisted.
|
||||||
|
pub struct SessionToken(String);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SessionToken {
|
||||||
|
/// Redacted so tokens cannot leak through logs or panic messages.
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("SessionToken(<redacted>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionToken {
|
||||||
|
pub(crate) fn generate() -> SessionToken {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
SessionToken(URL_SAFE_NO_PAD.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The secret value to return to the client. Deliberately not `Display`
|
||||||
|
/// so tokens cannot leak into logs by accident.
|
||||||
|
pub fn secret(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash used as the storage key for a token.
|
||||||
|
pub(crate) fn hash_token(secret: &str) -> String {
|
||||||
|
let digest = Sha256::digest(secret.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
use tc_auth::{AuthError, AuthService};
|
||||||
|
use tc_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||||
|
|
||||||
|
async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User) {
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
||||||
|
let user = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: "[email protected]".into(),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(pool, &user).await.unwrap();
|
||||||
|
(ws, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_login_authenticate_round_trip() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (ws, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
|
||||||
|
auth.set_password(user.id, "correct horse battery staple")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let token = auth
|
||||||
|
.login_local("[email protected]", "correct horse battery staple")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let authed = auth.authenticate(token.secret()).await.unwrap();
|
||||||
|
assert_eq!(authed.user_id, user.id);
|
||||||
|
assert_eq!(authed.workspace_id, ws.id);
|
||||||
|
assert_eq!(authed.role, Role::Owner);
|
||||||
|
assert!(authed.role.is_owner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wrong_password_is_rejected_without_detail() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
auth.set_password(user.id, "right").await.unwrap();
|
||||||
|
|
||||||
|
let err = auth
|
||||||
|
.login_local("[email protected]", "wrong")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AuthError::InvalidCredentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_email_is_the_same_error_as_wrong_password() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
let err = auth.login_local("[email protected]", "pw").await.unwrap_err();
|
||||||
|
// Indistinguishable from a wrong password: no account enumeration.
|
||||||
|
assert!(matches!(err, AuthError::InvalidCredentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn user_without_password_cannot_login_locally() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, _user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
let err = auth.login_local("[email protected]", "pw").await.unwrap_err();
|
||||||
|
assert!(matches!(err, AuthError::InvalidCredentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_token_is_unauthenticated() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
let err = auth.authenticate("not-a-real-token").await.unwrap_err();
|
||||||
|
assert!(matches!(err, AuthError::Unauthenticated));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expired_session_is_unauthenticated() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool.clone());
|
||||||
|
auth.set_password(user.id, "pw").await.unwrap();
|
||||||
|
let token = auth.login_local("[email protected]", "pw").await.unwrap();
|
||||||
|
|
||||||
|
sqlx::query("UPDATE auth_sessions SET expires_at = now() - interval '1 minute'")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = auth.authenticate(token.secret()).await.unwrap_err();
|
||||||
|
assert!(matches!(err, AuthError::Unauthenticated));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logout_invalidates_the_token() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
auth.set_password(user.id, "pw").await.unwrap();
|
||||||
|
let token = auth.login_local("[email protected]", "pw").await.unwrap();
|
||||||
|
|
||||||
|
auth.authenticate(token.secret()).await.unwrap();
|
||||||
|
auth.logout(token.secret()).await.unwrap();
|
||||||
|
let err = auth.authenticate(token.secret()).await.unwrap_err();
|
||||||
|
assert!(matches!(err, AuthError::Unauthenticated));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tokens_are_unique_per_login() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
auth.set_password(user.id, "pw").await.unwrap();
|
||||||
|
let a = auth.login_local("[email protected]", "pw").await.unwrap();
|
||||||
|
let b = auth.login_local("[email protected]", "pw").await.unwrap();
|
||||||
|
assert_ne!(a.secret(), b.secret());
|
||||||
|
// Both remain valid concurrently (multiple devices).
|
||||||
|
auth.authenticate(a.secret()).await.unwrap();
|
||||||
|
auth.authenticate(b.secret()).await.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Login sessions for both auth modes. Only a SHA-256 hash of the bearer
|
||||||
|
-- token is stored; possession of the database never yields valid tokens.
|
||||||
|
|
||||||
|
CREATE TABLE auth_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX auth_sessions_user_idx ON auth_sessions (user_id);
|
||||||
Reference in New Issue
Block a user