Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2783,6 +2783,10 @@
"$ref": "#/components/schemas/MemberRole",
"description": "The user's role in the tenant."
},
"state": {
"$ref": "#/components/schemas/TenantState",
"description": "The tenant's state (Laysi/trustiot-v2#777): while it is `suspended` or `deleted`, the\nsession reaches this service only, and every other request in the tenant answers 403\n`tenant_suspended`. A tenant without a record is `active`, as the cookie check reads it."
},
"tenant_id": {
"$ref": "#/components/schemas/TenantId",
"description": "The tenant."
Expand All @@ -2791,7 +2795,8 @@
"required": [
"tenant_id",
"name",
"role"
"role",
"state"
],
"type": "object"
},
Expand Down
10 changes: 7 additions & 3 deletions crates/contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
| `validation_failed` | 422 | 格式正確但違反規則;`field_errors` 指出欄位 |
| `unauthenticated` | 401 | session 或內部身分 token 缺少、無效或過期;前端導回登入 |
| `permission_denied` | 403 | 身分有效但沒有權限 |
| `tenant_suspended` | 403 | 租戶已停權(B1);前端只顯示停權說明頁 |
| `tenant_suspended` | 403 | 租戶已停權或已刪除(B1);前端只顯示停權說明頁。入口的 cookie 檢查、`Requirement::Tenant`、token 交換與 run 委派回這個代碼(Laysi/trustiot-v2#777) |
| `quota_exceeded` | 403 | 方案上限或配額用完(B2、B3);前端提供升級,重試無效 |
| `not_found` | 404 | 資源不存在,或呼叫者的租戶看不到 |
| `conflict` | 409 | 與目前狀態衝突,例如重複或同時修改 |
Expand Down Expand Up @@ -93,10 +93,11 @@
| 換到 | 200 | `ExchangedToken` `{"token"}`:同一個使用者、租戶、角色與平台管理員旗標,受眾是 `audience`,`act` 是呼叫端,`iat` 是現在,到期是原 token 的到期與 60 秒後取早者 |
| 沒有 token、token 無效或過期、1 秒內就到期 | 401 | `unauthenticated` |
| 呼叫端不能代表使用者呼叫 `audience`,或 token 本身是換來的(帶 `act`) | 403 | `permission_denied` |
| token 帶 `tenant_suspended` | 403 | `tenant_suspended` |
| 本文不是這個請求,或服務名稱不認得(含 `ai-gateway`,它不在 `ServiceName`) | 400 | `invalid_request` |
| 還沒有公鑰或簽章金鑰 | 503 | `unavailable` |

回應一律 `cache-control: no-store`。`ExchangedToken` 只能反序列化,沒有 `Serialize`,token 照樣是遮蔽內容的 `InternalToken`。經 `OnBehalf` 呼叫時,交換失敗是 `OnBehalfError::Exchange`:回給上游的代碼只有 `timeout`、`unavailable` 照傳,其他(含 403 與 401)一律 `internal`,因為那是部署的呼叫清單或本服務的問題,不是使用者的錯,同時記一筆 ERROR(呼叫端、受眾、身分服務回的代碼)。
回應一律 `cache-control: no-store`。`ExchangedToken` 只能反序列化,沒有 `Serialize`,token 照樣是遮蔽內容的 `InternalToken`。經 `OnBehalf` 呼叫時,交換失敗是 `OnBehalfError::Exchange`:回給上游的代碼只有 `timeout`、`unavailable` 與 `tenant_suspended` 照傳,其他(含 `permission_denied` 與 401)一律 `internal`,因為那是部署的呼叫清單或本服務的問題,不是使用者的錯,同時記一筆 ERROR(呼叫端、受眾、身分服務回的代碼)。

每種失敗都是 `CallError`,`code()` 給出要回給上游的 M1 代碼,`to_error_body()` 給出整個 body:

Expand Down Expand Up @@ -126,6 +127,7 @@ client 每次失敗都記一筆日誌,欄位有 `peer`、`route`(路由樣
| 建立 | 200 | `RunDelegationCreated` `{"delegation", "expires_at"}` |
| 沒有 token、token 無效或過期、沒有 `sid`、session 已不有效 | 401 | `unauthenticated` |
| token 是換來的(帶 `act`)、呼叫端不能持有委派、使用者已不是 token 租戶的成員 | 403 | `permission_denied` |
| token 帶 `tenant_suspended`,或租戶現在被停權或已刪除 | 403 | `tenant_suspended` |
| 本文不是這個請求、`key` 不是可用的 Ed25519 公鑰、效期超出範圍 | 400 | `invalid_request` |
| 還沒有公鑰或簽章金鑰、資料庫失敗 | 503 | `unavailable` |

Expand All @@ -136,10 +138,11 @@ client 每次失敗都記一筆日誌,欄位有 `peer`、`route`(路由樣
| 換到 | 200 | `ExchangedToken` `{"token"}`:使用者、委派的租戶與現在的角色、現在的平台管理員旗標,受眾是 `audience`,`act` 是持有者,`iat` 是現在,到期是 60 秒後與委派到期取早者 |
| 沒有委派、委派無效、過期或效期超過上限;proof 無效或超出時間窗;session 已不有效 | 401 | `unauthenticated` |
| 持有者不能呼叫 `audience`、使用者已不是委派租戶的成員 | 403 | `permission_denied` |
| 委派的租戶現在被停權或已刪除 | 403 | `tenant_suspended` |
| 本文不是這個請求,或服務名稱不認得 | 400 | `invalid_request` |
| 還沒有簽章金鑰、資料庫失敗 | 503 | `unavailable` |

回應一律 `cache-control: no-store`。委派對呼叫端是不透明的字串,`RunDelegationCreated` 只能反序列化,委派照樣是遮蔽內容的 `InternalToken`,只放在 `x-internal-identity` 送到換 token 的路徑。和 `OnBehalf` 不同,`RunDelegations` 把身分服務回的 `unauthenticated`、`permission_denied` 照傳給使用者:被拒絕的是使用者的 session 或成員資格,不是部署的問題。
回應一律 `cache-control: no-store`。委派對呼叫端是不透明的字串,`RunDelegationCreated` 只能反序列化,委派照樣是遮蔽內容的 `InternalToken`,只放在 `x-internal-identity` 送到換 token 的路徑。和 `OnBehalf` 不同,`RunDelegations` 把身分服務回的 `unauthenticated`、`permission_denied`、`tenant_suspended` 照傳給使用者:被拒絕的是使用者的 session、成員資格或租戶,不是部署的問題。

### AI 的唯讀 SQL(`api::telemetry_query::RunSql`)

Expand Down Expand Up @@ -209,6 +212,7 @@ payload 經身分服務的事件紀錄給租戶的管理員看,並隨租戶的
| `platform_admin` | 是否為平台管理員(A2),與租戶角色無關;缺少時視為 `false` |
| `act` | 選填,只有 token 交換換出的 token 有:`{"sub": "<服務>"}`,替使用者呼叫 `aud` 的服務(RFC 8693 的 actor,`Actor`)。入口 gateway 的請求簽出的 token 沒有它;身分服務不再換帶 `act` 的 token(Laysi/trustiot-v2#106) |
| `sid` | 選填,只有入口 gateway 的請求簽出的 token 有:使用者登入的 session 的 ID(`SessionId`),身分服務以它把 run 委派綁在 session 上(Laysi/trustiot-v2#551)。其他服務不必看它;它不是 credential,cookie 的值與它無關。換出的 token 沒有它 |
| `tenant_suspended` | 選填,只在 `tenant` 的租戶被停權或已刪除時出現,值是 `true`;缺少時視為 `false`(Laysi/trustiot-v2#777)。入口 gateway 只把帶它的 token 簽給身分服務,其他服務的請求在入口就回 403 `tenant_suspended`;需要租戶的端點(`Requirement::Tenant`)以 403 `tenant_suspended` 拒絕帶它的 token,token 交換與 run 委派也拒絕它 |

- claim 的名稱沿用 JWT(RFC 7519),claims 可以直接當 JWT payload。
- 驗證端忽略不認得的 claim,所以新 claim 只能是選填。
Expand Down
32 changes: 32 additions & 0 deletions crates/contract/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ pub struct InternalClaims {
/// ignore it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sid: Option<SessionId>,
/// Whether the token's tenant is suspended or deleted (checklist B1, Laysi/trustiot-v2#777).
/// The identity service reads the tenant's state with the role on every request and signs a
/// token of a suspended tenant for itself only, so the user can still read their session,
/// switch tenants, log out and change preferences there, and a platform administrator can
/// still resume a tenant; every other route is refused at the ingress with 403
/// `tenant_suspended`. Tenant-scoped endpoints refuse a token with it the same way
/// (`service_kit::identity::Requirement::Tenant`). Absent means `false`; never `true`
/// without a tenant.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tenant_suspended: bool,
}

/// The `act` claim of an exchanged token: who acts for the user.
Expand Down Expand Up @@ -190,9 +200,31 @@ mod tests {
platform_admin: false,
act: None,
sid: None,
tenant_suspended: false,
}
}

#[test]
fn the_suspension_claim_round_trips_and_is_omitted_when_false() {
let mut suspended = claims();
suspended.tenant_suspended = true;
let value = serde_json::to_value(&suspended).expect("serialize");
assert_eq!(value["tenant_suspended"], json!(true));
assert_eq!(
serde_json::from_value::<InternalClaims>(value).expect("deserialize"),
suspended
);
// An active tenant's token, and one from an identity service before
// Laysi/trustiot-v2#777, has no member; absent reads as `false`.
let value = serde_json::to_value(claims()).expect("serialize");
assert!(value.get("tenant_suspended").is_none(), "{value}");
assert!(
!serde_json::from_value::<InternalClaims>(value)
.expect("deserialize")
.tenant_suspended
);
}

#[test]
fn claims_use_jwt_names_and_round_trip() {
let value = serde_json::to_value(claims()).expect("serialize");
Expand Down
11 changes: 6 additions & 5 deletions crates/service-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ async fn routes(context: &Context<Config>, config: &Config) -> Router {
```

- `IdentityVerifier::new(受眾, 公鑰)`:受眾是服務自己的 `ServiceName`,寫在程式裡,不從設定讀。公鑰是 `PublishedKeys::watch` 讀到並持續更新的那一組(下方「公鑰與輪替」);測試可以直接給一組 `IdentityKeys`。
- handler 加上 `UserIdentity` 參數就要求這個請求帶有效的 token;沒通過的請求不會進 handler,直接回 401。`UserIdentity` 提供 `user_id()`、`tenant()`、`tenant_id()`、`is_platform_admin()` 與完整的 `claims()`。
- handler 加上 `UserIdentity` 參數就要求這個請求帶有效的 token;沒通過的請求不會進 handler,直接回 401。`UserIdentity` 提供 `user_id()`、`tenant()`、`tenant_id()`、`is_platform_admin()`、`is_tenant_suspended()`(token 的租戶被停權或已刪除,Laysi/trustiot-v2#777)與完整的 `claims()`。
- router 的 state 必須能以 `FromRef` 取出 `IdentityVerifier`,忘了放 verifier 的 handler 無法編譯。
- 需要 token 但不經 axum 的地方用 `IdentityVerifier::verify_headers(&HeaderMap)` 或 `verify(&str)`。
- 範例服務的 `GET /v1/whoami` 是可以執行的例子。
Expand Down Expand Up @@ -359,10 +359,11 @@ async fn delete_device(user: UserIdentity) -> Response {
| `Requirement` | 放行 | 拒絕的 `reason` |
| --- | --- | --- |
| `OwnRequest` | 使用者自己經入口的請求 | `exchanged_token`:帶 `act`,其他服務換發的 token |
| `Tenant(role)` | token 的租戶裡至少 `role`(admin 包含 editor,editor 包含 viewer);換發的 token 也可以 | `no_tenant`(平台管理員沒有租戶也是)、`unrecognized_role`(本版本不認得的角色,`TenantRole::Unrecognized` 不授予任何權限)、`role_too_low` |
| `Tenant(role)` | token 的租戶裡至少 `role`(admin 包含 editor,editor 包含 viewer),而且租戶沒有被停權;換發的 token 也可以 | `no_tenant`(平台管理員沒有租戶也是)、`tenant_suspended`(token 帶 `tenant_suspended`,在比對角色之前判斷)、`unrecognized_role`(本版本不認得的角色,`TenantRole::Unrecognized` 不授予任何權限)、`role_too_low` |
| `OwnPlatformAdmin` | 平台管理員本人的請求 | `not_platform_admin`、`exchanged_token` |

- 拒絕一律是同一個回應 `PermissionDenied`:403,body 是 M1 的 `{"code":"permission_denied","message_key":"error.permission_denied"}`,帶 `cache-control: no-store`,呼叫端看不出原因。
- 拒絕一律是 `PermissionDenied`:403,body 是 M1 的 `{"code":"permission_denied","message_key":"error.permission_denied"}`,帶 `cache-control: no-store`,呼叫端看不出原因。唯一的例外是 `tenant_suspended`:body 是 `{"code":"tenant_suspended","message_key":"error.tenant_suspended"}`,讓畫面說明租戶被停權,而不是說使用者沒有權限(Laysi/trustiot-v2#777)。
- 停權(清單 B1):身分服務的 ext_authz 不簽給其他服務停權租戶的 token(直接回 403 `tenant_suspended`),只有給身分服務自己的 token 帶 `tenant_suspended`;所以其他服務通常看不到這個 claim,只有停權前簽出、還在 60 秒效期內的 token 會通過。`OwnRequest` 與 `OwnPlatformAdmin` 不看它:這兩種要求不靠租戶。細節在 [services/identity/README.md](../../services/identity/README.md) 的「停權時的每次請求檢查」。
- 日誌是一行 INFO `refused to <action>`,欄位 `user_id`、`tenant_id`、`action`、`reason`、`exchanged`、`actor`(換發 token 的服務);不記 token。
- 「已登入」不需要檢查:只有驗證過的 token 才有 `UserIdentity`。
- `UserIdentity::meets(requirement)` 是同一個判斷、不記日誌也不產生回應,給 store 在開交易前的第二道檢查(例如 assets 的閘道器綁定、身分服務的成員角色);`UserIdentity::is_exchanged()` 回答 token 是否帶 `act`。
Expand Down Expand Up @@ -410,7 +411,7 @@ async fn device_certs(State(state): State<AppState>, user: UserIdentity) -> Resu
- 身分服務只替 chart 依 `mesh.calls` 列出的呼叫換 token(`TRUSTIOT_ON_BEHALF_CALLS`):要代表使用者呼叫一個服務,把它加進 `mesh.calls`,chart 同時讓本服務連得到對方與身分服務的 `token-exchange` 埠([chart README](../../deploy/chart/README.md) 的「服務間授權(mesh)」)。
- 只換一跳:清單套用在入口 gateway 簽給本服務的 token 上,換來的 token 帶 `act`,身分服務不再換它(403)。所以本服務以換來的 token 被呼叫時,不能再代表同一個使用者往下呼叫;多跳的鏈要等呼叫端的工作負載驗證(Laysi/trustiot-v2#509)。
- 換來的 token 不會比請求的 token 晚到期,最多 60 秒;請求快到期時(剩不到 1 秒)換不到。
- `OnBehalfError` 實作 `IntoResponse`,回 M1:交換失敗(`Exchange`)只有 `timeout`、`unavailable` 照傳,其他一律 `internal`,並記一筆 ERROR,欄位是呼叫端、受眾與身分服務回的代碼,提示檢查 `mesh.calls` 或請求的剩餘效期;呼叫對方失敗(`Call`)照 `CallError::code()`,例如對方的 `not_found` 原樣傳遞。token 不進日誌。
- `OnBehalfError` 實作 `IntoResponse`,回 M1:交換失敗(`Exchange`)只有 `timeout`、`unavailable` 與 `tenant_suspended`(使用者的租戶被停權,Laysi/trustiot-v2#777)照傳,其他一律 `internal`,並記一筆 ERROR,欄位是呼叫端、受眾與身分服務回的代碼,提示檢查 `mesh.calls` 或請求的剩餘效期;呼叫對方失敗(`Call`)照 `CallError::code()`,例如對方的 `not_found` 原樣傳遞。token 不進日誌。
- 身分服務自己用 `ExchangeVerifier` 驗證交換請求帶的 token:與 `IdentityVerifier` 做同樣的檢查,只是接受任何受眾。只有 token 交換可以用它;其他地方一律用 `IdentityVerifier`,受眾是自己。它目前是公開型別,其他服務也拿得到,限制它只給身分服務記在 Laysi/trustiot-v2#509。

### agent 執行期間的委派(`RunDelegations`)
Expand All @@ -430,7 +431,7 @@ let devices = run.call(&state.assets, &ListDevices { /* ... */ }).await?;
- 只有身分服務列為持有者的服務(目前只有 `assistant`)建立得了委派;其他服務拿到 403。
- `start` 為每個 run 以作業系統的 CSPRNG 產生一把 Ed25519 金鑰,公鑰交給身分服務綁進委派,私鑰只留在 `RunDelegation` 裡(drop 時清零);每次換 token 都以它簽 `run_proof_message(委派, 受眾, 現在)`。所以委派本身外洩(日誌、存下的 run)換不到 token。`RunDelegation` 不能序列化,`Debug` 只顯示 run、持有者與到期時間;clone 共用同一把金鑰,run 裡的平行工作可以同時呼叫。run 結束就 drop。
- 效期 1–900 秒(`RUN_DELEGATION_MAX_LIFETIME_SECS`),以整秒計,超出時 `start` 不送請求就回 `Lifetime`。到期後換不到 token,要以使用者的新請求重新開始。
- 身分服務每次換 token 都重查:session 結束(登出、撤銷、過期)後回 `unauthenticated`,使用者被移出租戶後回 `permission_denied`,改角色時下一張 token 就帶新角色。`RunDelegationError` 實作 `IntoResponse`:建立與換 token 被拒時把 `unauthenticated`、`permission_denied` 照傳給使用者(記 INFO,run 照預期結束),`timeout`、`unavailable` 照傳,其他一律 `internal`(記 ERROR);呼叫對方失敗(`Call`)照 `CallError::code()`。`permission_denied` 也可能是 `mesh.calls` 少了這條呼叫,身分服務的 WARN 日誌寫出 `not_listed`。
- 身分服務每次換 token 都重查:session 結束(登出、撤銷、過期)後回 `unauthenticated`,使用者被移出租戶後回 `permission_denied`,租戶被停權或已刪除後回 `tenant_suspended`(Laysi/trustiot-v2#777;恢復之後同一個委派又換得到),改角色時下一張 token 就帶新角色。`RunDelegationError` 實作 `IntoResponse`:建立與換 token 被拒時把 `unauthenticated`、`permission_denied`、`tenant_suspended` 照傳給使用者(記 INFO,run 照預期結束),`timeout`、`unavailable` 照傳,其他一律 `internal`(記 ERROR);呼叫對方失敗(`Call`)照 `CallError::code()`。`permission_denied` 也可能是 `mesh.calls` 少了這條呼叫,身分服務的 WARN 日誌寫出 `not_listed`。
- 受眾只到 `mesh.calls` 上本服務可以呼叫的服務,和 `OnBehalf` 同一份清單;換出的 token 帶 `act`,對方不能再換。

### 背景工作沒有使用者身分
Expand Down
Loading
Loading