docs: rebuild architecture and taskplans for role-site model

This commit is contained in:
Chris
2026-04-02 23:35:05 +08:00
parent 7cdf2b5a51
commit 16bbfdba24
8 changed files with 263 additions and 286 deletions

View File

@@ -1,26 +1,37 @@
# member.ose.tw 架構總覽
# member.ose.tw 架構總覽Keycloak 版)
## 核心模型
- 業務層:`companies -> sites -> users`
- 功能層:`systems -> modules`
- 權限層:`permission_groups`(群組中心)
- 業務層:`companies -> sites`
- 身分層:`users <-> sites`(多對多,透過 `user_sites`
- 能力層:`systems -> roles`
- 授權層:`sites <-> roles`(多對多,透過 `site_roles`
## 權限規則
- `scope_type` 固定 `site`
- `action``view` / `edit`(可同時存在)
- 權限透過群組下發給會員,不走細粒度 direct permission 主流程
## 權限模型(已定版)
- `permission` 正式改名為 `role`
- `role` 僅能指派給 `site`,不可直接指派給 `user`
- `user` 的有效角色由以下關聯推導:
- `user_sites`(使用者屬於哪些 site
- `site_roles`site 擁有哪些 role
- 不再使用舊的 `permission_groups` 主流程。
## Key 規則
- `system_key`: `SYyyyyMMddX####`
- `company_key`: `CPyyyyMMddX####`
- `site_key`: `STyyyyMMddX####`
- `role_key`: `RLyyyyMMddX####`
## Keycloak 同步策略
- Keycloak 為唯一 IdP。
- 群組階層:`Company Group -> Site SubGroup`
- 系統角色:以 Keycloak client role 表示,對應 DB `roles`
- `site_roles` 代表某 Site 擁有的 Keycloak role 集合。
- 使用者加入 Site 時,透過同步邏輯使其在 IdP 端取得對應角色能力。
## 後台安全線
- 所有 `/admin/*` Bearer token
- 後端僅依 `ADMIN_REQUIRED_GROUPS` 判定可否進後台
- 不在群組就算有網址、有 IdP 帳號也會 403
- `/admin/*` 必須 Bearer token
- 後端以 admin 群組白名單判定是否可進後台
- 有 Keycloak 帳號但不在 admin 白名單者,後台 API 一律拒絕。
## 會員資料與 IdP 對齊Keycloak 優先)
- `username`:登入帳號(可編輯,可同步)
- `display_name`:顯示名稱(可編輯,可同步到 IdP profile
- `user_sub`:由 IdP 主體識別值回寫
- `idp_user_id`:保存 IdP 端 user id字串供更新/密碼重設
## 密碼流程
- 目前:後台可觸發重設密碼(產生臨時密碼)
- SMTP 開通後:可再補「發送密碼設定/重設通知」自動化
## API 白名單
- 保留 `api_clients` 做系統對系統呼叫控管。
- 管理後台登入控管與 API client 白名單是兩條獨立安全線。

View File

@@ -1,17 +1,19 @@
# Backend TaskPlan
## 待辦
- [ ] 補 Keycloak SMTP 通知流程(密碼設定/重設寄信)
- [ ] `/admin/members` 關鍵操作審計日誌
- [ ] 補更多 API 測試members username/password reset 路徑)
- [ ] 重建 schema 與 migration退場舊表`modules` 舊權限流程、`permission_groups*``user_scope_permissions`),上線 `roles``site_roles``user_sites`
- [ ] 重做 Admin API`Company/Site/System/Role/User` CRUD 與關聯管理 API。
- [ ] 重做 Role API只允許 Site 指派/解除,不提供 user direct role API。
- [ ] 重做 `/me/permissions/snapshot`:改為 role 聚合格式(由 user_sites + site_roles 推導)。
- [ ] Keycloak 同步器改版Company/Site group 同步、System client role 同步、Site 角色套用同步。
- [ ] Swagger response model 全面更新為 role-site 新模型。
- [ ] 新增/補齊 API 測試CRUD、關聯、同步、刪除、錯誤碼
## 進行中
- [ ] 文件與式持續對齊(避免規格漂移)
- [ ] 文件與實際回應格式持續對齊(避免文件漂移)
## 已完成
- [x] `/admin/*` 改為 Bearer + admin 群組管控(`ADMIN_REQUIRED_GROUPS`
- [x] 管理 API 完成 systems/modules/companies/sites/members/permission-groups CRUD
- [x] 會員 upsert/update 可同步 Keycloak
- [x] 會員資料新增 `username` 欄位,與 `display_name` 分離
- [x] 新增 `POST /admin/members/{user_sub}/password/reset`
- [x] DB 新增 `users.username`(含 migration 腳本)
- [x] Keycloak OIDC 登入主流程authorize + callback + token
- [x] `/admin/*` Bearer token + admin 群組白名單安全線。
- [x] API 白名單基礎(`api_clients`)已存在並可管理。
- [x] 前後端本地啟動流程已可分開運行backend + frontend

View File

@@ -1,43 +1,105 @@
# DB Schema現行
# DB Schema新架構目標版
## 真實來源
- `backend/scripts/init_schema.sql`
- 線上增量:`backend/scripts/migrate_add_users_username.sql`
- 欄位重命名增量:`backend/scripts/migrate_rename_identity_columns.sql`
> 本文件是新架構的目標資料模型,供後端 schema 重建與 migration 依據。
> DB 真實來源仍以 `backend/scripts/init_schema.sql` 為準。
## 主要表
- `users`
- `user_sub` UNIQUE
- `idp_user_id` VARCHAR(128)
- `username` UNIQUE
- `email` UNIQUE
- `display_name`
- `is_active`, `status`, timestamps
- `companies`
- `sites``company_id -> companies.id`
- `systems`
- `modules``system_key -> systems.system_key`
- `permission_groups`
- `permission_group_members`group + user_sub
- `permission_group_permissions`group + site/system/module/action
- `user_scope_permissions`(相容保留)
- `api_clients`(保留給機器對機器用途)
## 1) companies
- `id` UUID PK default `gen_random_uuid()`
- `company_key` TEXT NOT NULL UNIQUE
- `display_name` TEXT NOT NULL
- `legal_name` TEXT
- `idp_group_id` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 權限規則
- `scope_type='site'`
- `action in ('view','edit')`
## 2) sites
- `id` UUID PK default `gen_random_uuid()`
- `site_key` TEXT NOT NULL UNIQUE
- `company_id` UUID NOT NULL FK -> `companies(id)` ON DELETE CASCADE
- `display_name` TEXT NOT NULL
- `domain` TEXT
- `idp_group_id` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 會員與 IdP 對齊Keycloak 優先)
- `users.user_sub` 對應 IdP 主體識別
- `users.username` 對應 IdP `username`
- `users.display_name` 對應 IdP 顯示名稱
## 3) systems
- `id` UUID PK default `gen_random_uuid()`
- `system_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `idp_client_id` TEXT NOT NULL UNIQUE
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 快速檢查 SQL
```sql
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name='users'
ORDER BY ordinal_position;
## 4) roles
- `id` UUID PK default `gen_random_uuid()`
- `role_key` TEXT NOT NULL UNIQUE
- `system_id` UUID NOT NULL FK -> `systems(id)` ON DELETE CASCADE
- `name` TEXT NOT NULL
- `description` TEXT
- `idp_role_name` TEXT NOT NULL
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`system_id`, `idp_role_name`)
SELECT COUNT(*) FROM users WHERE username IS NULL;
```
## 5) site_roles
- `id` UUID PK default `gen_random_uuid()`
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
- `role_id` UUID NOT NULL FK -> `roles(id)` ON DELETE CASCADE
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`site_id`, `role_id`)
## 6) users
- `id` UUID PK default `gen_random_uuid()`
- `user_sub` TEXT NOT NULL UNIQUE
- `idp_user_id` TEXT UNIQUE
- `username` TEXT UNIQUE
- `email` TEXT UNIQUE
- `display_name` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `is_active` BOOLEAN NOT NULL default `true`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 7) user_sites
- `id` UUID PK default `gen_random_uuid()`
- `user_id` UUID NOT NULL FK -> `users(id)` ON DELETE CASCADE
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`user_id`, `site_id`)
## 8) auth_sync_state
- `id` UUID PK default `gen_random_uuid()`
- `entity_type` VARCHAR(32) NOT NULL
- `entity_id` UUID NOT NULL
- `last_synced_at` TIMESTAMPTZ
- `source_version` TEXT
- `last_error` TEXT
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`entity_type`, `entity_id`)
## 9) api_clients
- `id` UUID PK default `gen_random_uuid()`
- `client_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `status` VARCHAR(16) NOT NULL default `'active'`
- `api_key_hash` TEXT NOT NULL
- `allowed_origins` JSONB NOT NULL default `'[]'::jsonb`
- `allowed_ips` JSONB NOT NULL default `'[]'::jsonb`
- `allowed_paths` JSONB NOT NULL default `'[]'::jsonb`
- `rate_limit_per_min` INTEGER
- `expires_at` TIMESTAMPTZ
- `last_used_at` TIMESTAMPTZ
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 關聯總結
- Company 1:N Site
- System 1:N Role
- Site M:N Role`site_roles`
- User M:N Site`user_sites`
- User 最終角色由 Site 推導,不做 user direct role 指派。

View File

@@ -1,17 +1,43 @@
# Frontend Handoff
# Frontend HandoffRole-Site 模型)
## 目前後端契約重點
- 後台登入:只吃 Bearer + admin 群組檢查
- 會員模型:`user_sub`, `username`, `email`, `display_name`, `is_active`
- 會員密碼:支援重設 API回傳臨時密碼
## 目
前端只實作新模型,不再使用舊 `permission_groups` / `module-action` 流程。
## 會員頁必做
1. 新增會員表單欄位:`username``email``display_name`
2. 編輯會員表單欄位:`username``email``display_name``is_active`
3. 表格欄位要顯示:`user_sub``username``email``display_name`
4. 操作欄新增「重設密碼」按鈕,串 `POST /admin/members/{user_sub}/password/reset`
5. 重設成功後顯示臨時密碼,並提醒管理員安全轉交
## 主要頁面
1. 公司管理CRUD
- 欄位:`company_key`, `display_name`, `legal_name`, `status`
- 詳情頁需顯示底下 `sites` 列表
## 其他頁面
- 仍維持群組中心模型site/system/module/member + action(view/edit)
- 系統/模組/公司/會員關聯頁面沿用目前 API
2. 站台管理CRUD
- 欄位:`site_key`, `company_id`, `display_name`, `domain`, `status`
- 站台詳情需顯示:
- 此站台綁定的 `roles`
- 此站台包含的 `users`
3. 系統管理CRUD
- 欄位:`system_key`, `name`, `idp_client_id`, `status`
- 系統詳情需顯示底下 `roles` 列表
4. 角色管理CRUD
- 欄位:`role_key`, `system_id`, `name`, `description`, `idp_role_name`, `status`
- 關聯操作:指派到 Site新增/刪除 `site_roles`
5. 會員管理CRUD
- 欄位:`user_sub`, `username`, `email`, `display_name`, `is_active`, `status`
- 關聯操作:加入/移除 Site新增/刪除 `user_sites`
- 顯示推導角色(唯讀)
6. API Clients 管理CRUD
- 欄位:`client_key`, `name`, `status`, `allowed_origins`, `allowed_ips`, `allowed_paths`, `rate_limit_per_min`, `expires_at`
## 前端互動規則
- 角色不可直接綁會員UI 不提供此操作)。
- Site 與 User 的關聯調整後,角色顯示即時刷新。
- 刪除操作一律二次確認,顯示影響提示。
## 驗收重點
- 看不到舊 permission group 流程。
- 可以完整做:
- Site 綁 Role
- User 綁 Site
- 顯示 User 推導角色

View File

@@ -1,16 +1,23 @@
# Frontend TaskPlan
## 待辦
- [ ] SMTP 通知開通後補上「發送重設通知」UX 文案
- [ ] 會員頁重設密碼流程加上二次確認 Dialog
- [ ] 針對大量資料頁面補分頁/搜尋體驗優化
- [ ] 後台導覽改版:改為 `公司 / 站台 / 系統 / 角色 / 會員 / API Clients`
- [ ] 角色管理頁改成「Site 綁 Role」
- [ ] 可查看 Site 目前擁有角色
- [ ] 可新增/移除 Site 角色
- [ ] 會員管理頁改成「User 綁 Site」
- [ ] 可查看使用者目前所屬 Site
- [ ] 可新增/移除使用者 Site
- [ ] 顯示推導角色(只讀)
- [ ] 公司詳情頁顯示站台列表。
- [ ] 系統詳情頁顯示角色列表。
- [ ] 補齊刪除流程與二次確認(公司/站台/系統/角色/會員)。
- [ ] API 失敗狀態頁統一401/403/409/422/500
## 進行中
- [ ] 與最新後端契約持續對齊members username/password reset
- [ ] OIDC callback 與 token 持久化穩定性檢查。
## 已完成
- [x] Vue3 + JS + Vite + Element Plus + Tailwind 基礎架構
- [x] admin 基礎頁面systems/modules/companies/sites/members/permission-groups
- [x] 會員頁新增 `username` 欄位(新增/編輯/列表)
- [x] 會員頁新增「重設密碼」操作按鈕
- [x] OIDC 登入 + `/me``/me/permissions/snapshot` 流程
- [x] Vue3 + JS + Vite + Element Plus + Tailwind 基礎框架。
- [x] OIDC 登入按鈕導轉與 callback 路由骨架。
- [x] 基礎 admin 頁面與 API service 分層已建立。

View File

@@ -1,190 +1,38 @@
# Internal API Handoff
# Internal API Handoff(新模型)
## Base URL
- Local: `http://127.0.0.1:8000`
- Prod: 由部署環境提供
## Auth Headers每支 `/internal/*` 都必帶
## Auth Headers`/internal/*`
- `X-Client-Key: <client_key>`
- `X-API-Key: <api_key>`
## Common Error Response
```json
{
"detail": "error_code"
}
{ "detail": "error_code" }
```
常見 `detail`
- `invalid_client`401
- `invalid_api_key`401
- `client_expired`401
- `origin_not_allowed`403
- `ip_not_allowed`403
- `path_not_allowed`403
## 資源模型(重點)
- `company`: `id`, `company_key`, `display_name`, `legal_name`, `status`
- `site`: `id`, `site_key`, `company_id`, `display_name`, `domain`, `status`
- `system`: `id`, `system_key`, `name`, `idp_client_id`, `status`
- `role`: `id`, `role_key`, `system_id`, `name`, `description`, `idp_role_name`, `status`
- `user`: `id`, `user_sub`, `username`, `email`, `display_name`, `is_active`, `status`
## Endpoints
## 主要端點(目標)
1. `GET /internal/companies`
2. `GET /internal/sites`
3. `GET /internal/systems`
4. `GET /internal/roles`
5. `GET /internal/users`
6. `GET /internal/users/{user_sub}/roles`
- 回傳該 user 透過 site 推導出的最終 roles。
### GET `/internal/systems`
Response:
```json
{
"items": [
{ "id": "uuid", "system_key": "ST20260331X1234", "name": "Marketing", "status": "active" }
],
"total": 1,
"limit": 200,
"offset": 0
}
```
## 關聯端點(目標)
1. `POST /internal/site-roles` / `DELETE /internal/site-roles/{id}`
2. `POST /internal/user-sites` / `DELETE /internal/user-sites/{id}`
### GET `/internal/modules`
Response:
```json
{
"items": [
{
"id": "uuid",
"module_key": "MD20260331X5678",
"system_key": "ST20260331X1234",
"name": "Campaign",
"status": "active"
}
],
"total": 1,
"limit": 500,
"offset": 0
}
```
### GET `/internal/companies`
Query:
- `keyword`optional
- `limit`default 500
- `offset`default 0
Response:
```json
{
"items": [
{ "id": "uuid", "company_key": "CP20260331X9999", "name": "OSE", "status": "active" }
],
"total": 1,
"limit": 500,
"offset": 0
}
```
### GET `/internal/sites`
Query:
- `company_key`optional
- `limit`default 500
- `offset`default 0
Response:
```json
{
"items": [
{
"id": "uuid",
"site_key": "ST20260331X1111",
"company_key": "CP20260331X9999",
"name": "main-site",
"status": "active"
}
],
"total": 1,
"limit": 500,
"offset": 0
}
```
### GET `/internal/members`
Query:
- `keyword`optional
- `limit`default 500
- `offset`default 0
Response:
```json
{
"items": [
{
"id": "uuid",
"user_sub": "idp-uid",
"username": "chris",
"email": "chris@ose.tw",
"display_name": "Chris",
"is_active": true
}
],
"total": 1,
"limit": 500,
"offset": 0
}
```
### POST `/internal/users/upsert-by-sub`
Request:
```json
{
"user_sub": "idp-uid",
"username": "chris",
"email": "chris@ose.tw",
"display_name": "Chris",
"is_active": true
}
```
Response:
```json
{
"id": "uuid",
"user_sub": "idp-uid",
"idp_user_id": "idp-user-id-or-uuid",
"username": "chris",
"email": "chris@ose.tw",
"display_name": "Chris",
"is_active": true
}
```
### GET `/internal/permissions/{user_sub}/snapshot`
Response:
```json
{
"user_sub": "idp-uid",
"permissions": [
{
"scope_type": "site",
"scope_id": "ST20260331X1111",
"system": "ST20260331X1234",
"module": "MD20260331X5678",
"actions": ["view", "edit"]
}
]
}
```
### POST `/internal/idp/users/ensure`
(相容路徑:`/internal/idp/users/ensure`
Request:
```json
{
"user_sub": "idp-uid",
"email": "user@example.com",
"username": "user1",
"display_name": "User One",
"is_active": true
}
```
Response:
```json
{
"idp_user_id": "idp-user-id-or-uuid",
"action": "created"
}
```
`action` 可能值:`created` / `updated`
## 注意事項
- 不提供 user direct role 寫入 API。
- 若其他系統需要判斷某 user 可否做某事,請吃 `users/{user_sub}/roles` 聚合結果。

View File

@@ -1,4 +1,4 @@
# Local Dev Runbook
# Local Dev RunbookKeycloak
## 1) 啟動後端
```bash
@@ -13,18 +13,29 @@ npm install
npm run dev
```
## 3) 要環境變數
- `backend/.env.development`
- `ADMIN_REQUIRED_GROUPS=member-admin`
- `KEYCLOAK_*`
## 3) 要環境變數backend/.env.development
- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- `ADMIN_REQUIRED_GROUPS`
## 4) 基本檢查
- `GET http://127.0.0.1:8000/healthz`
- 登入後打 `GET /me` 應可回資料
- 非 admin 群組帳號打 `/admin/*` 應回 `403`
1. `GET http://127.0.0.1:8000/healthz` 應為 200。
2. 前端按「前往 Keycloak 登入」應可成功導轉與回跳。
3. `GET /me` 登入後應有資料。
4. 非 admin 群組帳號打 `/admin/*` 應為 403。
## 5) 會員流程驗收
1. 新增會員(開啟 `sync_to_idp`
2. 確認列表可看到新會員與 `user_sub`
3. 點「重設密碼」,取得臨時密碼
4. 到 Keycloak 驗證該會員可用新密碼登入
## 5) 新模型驗收路徑
1. 新增 Company、Site。
2. 新增 System、Role。
3. 對 Site 指派 Role。
4. 新增 User加入 Site。
5. 驗證 User 的角色是由 Site 推導,不是 direct assign。
## 6) API 白名單驗收
1. 建立 `api_client`
2.`X-Client-Key` + `X-API-Key` 呼叫 `/internal/*`
3. 驗證未授權 key 會被拒絕。

View File

@@ -1,4 +1,4 @@
# member.ose.tw 文件入口
# member.ose.tw 文件入口(新架構)
## 閱讀順序
1. `docs/ARCHITECTURE.md`
@@ -6,16 +6,26 @@
3. `docs/BACKEND_TASKPLAN.md`
4. `docs/FRONTEND_TASKPLAN.md`
5. `docs/FRONTEND_HANDOFF.md`
6. `docs/LOCAL_DEV_RUNBOOK.md`
6. `docs/INTERNAL_API_HANDOFF.md`
7. `docs/LOCAL_DEV_RUNBOOK.md`
## 交辦順序(建議)
1. 先看 `ARCHITECTURE.md` 鎖定資料模型與權限模型。
2. 再看 `DB_SCHEMA.md` 對齊 table/欄位/關聯。
3. 後端依 `BACKEND_TASKPLAN.md` 執行 schema/API/Keycloak 同步調整。
4. 前端依 `FRONTEND_TASKPLAN.md` + `FRONTEND_HANDOFF.md` 開工。
5. 其他系統串接時看 `INTERNAL_API_HANDOFF.md`
## 目前狀態
- 架構:公司/站台/會員 + 系統/模組 + 群組整合權限(已定版)
- 後台安全Auth token + admin 群組檢查(`ADMIN_REQUIRED_GROUPS`
- 會員流程member 新增/更新可同步 Keycloak並支援重設密碼
- 架構定版:`Company -> Site``System -> Role`
- 權限定版:`Role` 只能指派給 `Site`(透過 `site_roles`
- 成員授權定版:`User` 不直接綁 `Role`,僅透過 `user_sites` 取得 Site 角色。
- IdP 定版Keycloak 為唯一 IdP。
- API 白名單:保留 `api_clients`
## 單一真實來源
- DB SQL`backend/scripts/init_schema.sql`
- DB 線上補丁:`backend/scripts/migrate_add_users_username.sql`
## 備註
- 本輪先維持可開發/可交辦文件,不產最終規格總表。
## 文件邊界
- 本輪只保留可開發可交辦、可驗收文件。
- 最終規格白皮書延後到專案完成後再產出。