API 存取矩陣(api-access-matrix)Integration Guide — Cortex Management
Backend branch: feat/api-access-matrix(PR-A1)→ cortex/dev
生效條件:live PostgreSQL 先套 migration 170_api_access_matrix.sql,再部署 API(順序見 §6)
Consumers:
- Cortex Management:新增「API 存取矩陣」頁與額度顯示;舊的等級權限頁(
/api/admin/membership-tiers)見 §5 - 會員端 App / Web:本版無任何變化(觀察模式,不擋請求)
1. 本版(PR-A1)出貨內容
本版只出貨觀察模式:矩陣可以讀,但沒有任何東西被強制執行。
| 項目 | 狀態 |
|---|---|
GET /api/admin/api-access(每支 managed API × 等級的規則) |
本版出貨 |
GET /api/admin/membership-tiers/{tier_name}/quotas(等級額度) |
本版出貨 |
| 觀察模式閘門(解析請求對應的路由並記 log) | 本版出貨;絕不拒絕請求 |
migration 170:api_access_rules、membership_tier_quotas、保留等級 visitor、種子 |
本版出貨 |
矩陣強制執行、PUT 寫入 API、舊 gate 移除 |
尚未上線(PR-A2,見 §3、§4) |
本版的矩陣內容是「上線前行為的快照」:219 支 managed API 的舊分類(PUBLIC 151 / LOGIN 58 / PAID 3 / REALTIME 7)依 live 等級設定推導成每個等級的可用/不可用。現在實際擋請求的仍是既有機制(登入、ai_analysis、realtime_quote、watchlist 額度);在矩陣頁看到的值是「A2 強制執行後會採用的值」,改了也還不會影響任何請求(而且本版也沒有寫入 API)。
路由分四類(PR-A1 凍結時 api.main.app 共 301 個 operation,62 + 18 + 2 + 219 = 301;之後新增/移除的 managed 路由以 UNSEEDED_ROUTE_KEYS/REMOVED_ROUTE_KEYS 為準):admin(62,後台)、auth_infra(18,/api/auth/*、/api/members/me*、/、/health)與 internal(2,/api/internal/* 以服務 token 驗證的機器對機器端點)不進矩陣;只有 managed(219)進矩陣。完整說明見 api-reference.md「API 存取矩陣(授權總則)」。
2. Contract(TypeScript)
兩支 endpoint 皆需 admin Bearer token(無效 token 401、非 admin 403)。
// GET /api/admin/api-access
type ApiAccessTier = {
name: string; // 'visitor' | 'free' | 'advance' | ...;admin 不會出現
display_name: string;
level: number; // visitor = -1
is_active: boolean; // visitor = false
reserved: boolean; // visitor = true(保留名稱:admin、visitor;不可指派給會員)
};
type ApiAccessEndpoint = {
key: string; // "METHOD /path",例如 "GET /api/portfolio/holdings"
method: string; // 大寫
path: string; // 完整路徑,已去掉 converter("{rule_id:path}" → "{rule_id}")
tags: string[]; // OpenAPI tags;目前 219 支都恰有 1 個 tag
summary: string | null; // 目前 219 支中 198 支為 null,請以 key / path 作為主要標示
requires_member: boolean;
conditional_login: boolean;
rules: Record<string, boolean | null>; // key = ApiAccessTier.name;true 可用 / false 不可用 / null 未設定
};
type ApiAccessOrphan = {
method: string;
path: string;
tier: string;
allowed: boolean;
};
type ApiAccessMatrixResponse = {
tiers: ApiAccessTier[]; // 依 (level, name) 排序:visitor 在最前
endpoints: ApiAccessEndpoint[]; // 依 key 排序,目前 219 筆
orphans: ApiAccessOrphan[];
};
// GET /api/admin/membership-tiers/{tier_name}/quotas
type TierQuotasResponse = {
tier: string;
quotas: Record<string, number | null>; // 目前 key:max_watchlists、max_watchlist_items;-1 = 無限制;null = 該等級沒有額度列
updated_at: string; // ISO 8601 (UTC);A2 的 PUT 以它做 expected_updated_at
};
顯示規則:
| 欄位/情況 | UI 行為 |
|---|---|
rules[tier] === true |
可用 |
rules[tier] === false |
不可用 |
rules[tier] === null |
未設定(沒有規則列;A2 強制執行後等同不可用,請用與「不可用」不同的樣式標示) |
requires_member === true |
此 API 結構上必須登入(目前 68 支);鎖住 visitor 欄(顯示不可用並 disabled)。A2 的 PUT 也會拒絕把這些 API 對 visitor 設為可用 |
conditional_login === true |
未登入也能呼叫、但登入與否行為不同(目前只有 GET /api/stock-lists/items);不鎖欄,加一則備註 |
| 分組 | 依 tags[0] 分組(目前 47 組),或自行用 path 前綴;後端不規定分組方式 |
orphans |
資料庫有規則列、但對應路由已不是 managed 路由(或等級為 admin);顯示為「可清理」清單,供 PR-B 清理,本版不能刪 |
tiers[].is_active === false 且 reserved === false |
仍會列為欄位(inactive 等級也在回應內),是否顯示由前端決定 |
錯誤:
| HTTP / code | 端點 | UI 行為 |
|---|---|---|
401 INVALID_TOKEN/TOKEN_EXPIRED |
兩支 | 導向登入 |
403 ADMIN_REQUIRED |
兩支 | 顯示無權限 |
404 MEMBERSHIP_TIER_NOT_FOUND |
quotas | 等級不存在,重新載入 tiers |
503 MEMBERSHIP_CONFIGURATION_INVALID |
quotas | 等級設定錯誤(updated_at 缺失),顯示系統設定問題 |
3. 預定的寫入契約(PR-A2,尚未上線)
以下 endpoint 本版不存在,呼叫會得到 405(路徑已存在但只支援 GET)。 契約先提供給 Management 提前開工;PR-A2 實作時若有調整,以 A2 的 OpenAPI 與本文件更新為準。
PUT /api/admin/api-access
批次修改規則:
type ApiAccessChange = {
method: string; // "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
path: string; // managed 路由的 path(同 endpoints[].path)
tier: string; // tiers[].name;不可為 admin
allowed: true | false | null; // null = 刪除規則列,回到「未設定」
};
type ApiAccessUpdateRequest = {
changes: ApiAccessChange[];
reason: string;
};
驗證規則:(method, path) 必須是 managed 路由;tier 不可為 admin;requires_member 的 API 不可對 visitor 設為 true。每次寫入記 audit 事件 admin_api_access_rules_updated(含修改前後的值)。
PUT /api/admin/membership-tiers/{tier}/quotas
整組取代該等級的額度(PR-A2,尚未上線):
type TierQuotasUpdateRequest = {
quotas: {
max_watchlists: number; // 整數;-1 = 無限制
max_watchlist_items: number; // 整數;-1 = 無限制
};
expected_updated_at: string; // ISO 8601(含 offset),取自 GET .../quotas 的 updated_at
reason: string; // 1-500 字元
};
- 所有額度 key(目前
max_watchlists、max_watchlist_items)都必須出現;-1表示無限制。 expected_updated_at是樂觀鎖,模式同現有權限 PUT;版本過期回409 MEMBERSHIP_TIER_VERSION_CONFLICT,請重新GET .../quotas後再存。寫入時會更新membership_tiers.updated_at。- 保留等級(
admin、visitor)回400 INVALID_MEMBERSHIP_TIER。
舊權限 PUT 的變化
PUT /api/admin/membership-tiers/{tier_name}/permissions:A2 起,若 features 內任何存取類 key(ai_analysis、realtime_quote)或額度 key(max_watchlists、max_watchlist_items)與資料庫現值不同,回 409 FEATURES_MANAGED_BY_API_ACCESS(指向新 API),避免 A2 到 PR-B 之間「存了沒效」。其餘 key 的行為不變。
4. A2 之後矩陣會強制的行為(尚未上線)
| 情境 | A2 之後 |
|---|---|
admin 會員(members.is_admin = true) |
一律放行 |
訪客(無 token)打該 API 對 visitor 為 false 或未設定的 managed API |
401 LOGIN_REQUIRED(details: {endpoint}) |
| 已登入非 admin 會員,所屬等級對該 API 為 false 或未設定 | 403 FEATURE_REQUIRED(details: {endpoint, tier}) |
該 API 對 visitor 與所有 active 等級皆為 true |
放行,且不驗證 token |
| 新增、尚未登記種子的 managed API | 對所有非 admin 呼叫者不可用(未設定),需管理員在矩陣頁開通 |
缺 Authorization header 打需登入的端點 |
由 422 變 401 LOGIN_REQUIRED |
INVALID_TOKEN/TOKEN_EXPIRED 的 token 打 visitor 可用的 API |
當訪客處理(維持公開端點現況);打 visitor 不可用的 API 仍回原本的 401 |
| 其他驗證錯誤:帳號停權、不存在、授權設定錯誤 | 照原樣回傳原錯誤碼(403 / 404 / 503) |
| 矩陣資料表缺失 | managed 端點 503 API_ACCESS_SCHEMA_MISSING(fail-closed) |
| SSE(3 支即時串流) | 連線時判定;串流中每一輪重新驗證,被改為不可用時送 unauthorized 並結束 |
| 7 支即時報價/盤中 K 端點 | 種子依 live features.realtime_quote(migration 168 初始值除 admin 外皆為 false);admin 一律放行,其餘等級只有在該等級原本已開啟時才為可用 |
401 LOGIN_REQUIRED、403 FEATURE_REQUIRED 的 envelope 沿用既有格式({"error": {"code", "message", "details"}})。
上表兩項已由使用者核可(2026-10-21),是 PR-A2 將上線的既定行為:訪客被拒一律回 401 LOGIN_REQUIRED,因此缺 Authorization header 打需登入的端點會由 422 變 401;INVALID_TOKEN/TOKEN_EXPIRED 的 token 打 visitor 可用的端點時當訪客處理,其他驗證錯誤(停權 403、不存在 404、設定 503)照原樣回傳。
5. 現有等級權限頁
- 後端
PUT /api/admin/membership-tiers/{tier_name}/permissions的features為extra="forbid"且realtime_quote必填(api/routes/admin_memberships.py的TierFeatures);Management 舊頁存檔時寫死 4 個 key(src/app/(admin)/subscriptions/tiers/page.tsx:136-162,對現在後端實測 422)。在送出realtime_quote之前,舊頁存檔仍會 422(同 realtime-quote-tier-gate-frontend.md §2),本版沒有改變這點。 - 舊頁將由矩陣頁與額度頁取代(A2 之後
realtime_quote、ai_analysis、額度不再經由此 PUT 修改,見 §3);不需要為舊頁另補相容。 - 依程式碼(
GET /api/admin/membership-tiers的查詢沒有排除保留等級):套用 migration 170 後,?include_inactive=true會多回一筆visitor(is_active=false、reserved=true、features={})。舊頁若用這個清單渲染權限開關,請略過reserved === true的項目,或對缺少的featureskey 做防呆。
6. 部署順序與遷移 checklist
- [ ] 後端:live PostgreSQL 套 migration 170(SIT 套用方式同 migration 168 的流程);套前先跑下面的查詢,結果必須為空(列出的是會讓 migration 整支中止的值:JSON 字串如
"3"、小數、null、小於-1、以及保守起見的 10 位數以上——int4 實際上限 2147483647,10 位數的值先人工確認),非空先修資料:SELECT t.name, k.quota_key, t.features -> k.quota_key AS stored_value FROM membership_tiers t CROSS JOIN (VALUES ('max_watchlists'), ('max_watchlist_items')) AS k(quota_key) WHERE t.features ? k.quota_key AND (t.features -> k.quota_key)::text !~ '^(-1|0|[1-9][0-9]{0,8})$';(缺 key 的等級不種額度列,不算錯) - [ ] 後端:部署 API。PR-A1 沒有行為變化:觀察模式閘門只解析路由、不查資料庫、不拒絕請求
- [ ] 驗證:
GET /api/admin/api-access的endpoints筆數 = 219、orphans為空;7 支即時報價端點(GET /api/stocks/{ticker}/realtime-quote等)的rules與 live 各等級的features.realtime_quote一致(migration 168 初始值下皆為false) - [ ] Cortex Management:矩陣頁(唯讀)與額度顯示依 §2 接上;寫入 UI 等 PR-A2
- [ ] PR-A2 開啟強制執行之前:以當下的
membership_tiers.features核對api_access_rules與membership_tier_quotas(A1 種子是套用當下的快照;A2 之前管理員仍可經舊權限 PUT 修改features)
順序風險:程式先上而 migration 170 未套 → 這兩支新 GET 因資料表不存在而失敗,其他端點不受影響;migration 先套而程式未上 → 沒有行為影響(新表與 visitor 列無人讀取),唯一可見差異是 GET /api/admin/membership-tiers?include_inactive=true 多一筆 visitor(見 §5)。
回滾:先退程式,再執行
DROP TABLE membership_tier_quotas; DROP TABLE api_access_rules; DELETE FROM membership_tiers WHERE name='visitor'; DELETE FROM schema_migrations WHERE filename='170_api_access_matrix.sql';
(features JSON 本階段不刪,退回即恢復。)
7. 測試來源
- 路由登錄表與觀察模式閘門:
investment-agent/backend/tests/test_route_registry.py(key 集合 = OpenAPI paths × methods、admin = 權限守門的 admin 清單、auth_infra 18、internal 2、managed = 其餘全部且 = golden ∪UNSEEDED_ROUTE_KEYS−REMOVED_ROUTE_KEYS、requires_member/conditional_login不變式、閘門絕不影響請求、建表失敗只建一次) - 後台 GET 兩支與保留名稱/額度 key 單一定義:
investment-agent/backend/tests/test_admin_api_access.py、真實 PostgreSQL:investment-agent/backend/tests/integration/test_admin_api_access_pg.py(矩陣 = golden × visitor 種子、合成等級的規則列與孤兒列、額度往返與 404、非 admin 403) - 指派服務:
investment-agent/backend/tests/integration/test_membership_service_pg.py(真實 PostgreSQL;visitor/admin保留等級被INVALID_MEMBERSHIP_TIER拒絕,含強制啟用後仍被拒;啟用中的一般等級可指派並寫入 entitlement 與current_tier_id) - 守門:
investment-agent/backend/tests/test_endpoint_auth_gating.py(新兩支路由須歸類為 admin)、investment-agent/backend/tests/test_api_access_seed_coverage.py(新增 managed API 只有一條路:列入UNSEEDED_ROUTE_KEYS,強制執行後對非 admin 不可用,由管理員在矩陣頁開通;不再以新 migration 補種子列。已移除的 golden 路由列入REMOVED_ROUTE_KEYS,其規則列留在orphans[]直到 PR-B;兩份清單不得列已失效的 key;7 支即時端點須為 REALTIME) - migration 170:
investment-agent/backend/tests/test_migration_170_api_access_matrix.py(結構契約、VALUES區塊 = golden 檔)、真實 PostgreSQL:investment-agent/backend/tests/integration/test_migration_170_api_access_matrix_pg.py(種子 = golden × 等級推導、7 支即時端點各等級 false、額度複製、冪等、非布林的 feature flag 一律 deny、管理員改過的列重跑不被蓋掉、被刪除的列重跑重新種入、額度值為非數字字串、數字字串"3"、小數、null、超出 int4 或小於-1時整支中止,各自對應 cast/range/CHECK 錯誤) - golden 快照:
investment-agent/backend/tests/data/api_access_golden_routes.json,PR-A1 由scripts/generate_api_access_golden.py產生後凍結,不可重新產生或手改(--check只驗證快照內、app 仍提供的路由分類沒變)