# Permission 模組

Gateway 多租戶 **B2B 自定義 RBAC**：平台級 Permission Catalog + 租戶級 Role / RolePermission / UserRole / RoleMapping + Casbin enforcer。

- **規格書**（Data Dictionary、API 端點欄位、Casbin model）→ [`SDD.md`](./SDD.md)
- **跨模組總覽** → [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §6
- 本 README = 流程圖 + curl + ServiceContext 速查

---

## TL;DR

```mermaid
flowchart LR
    subgraph Platform["平台層"]
      Catalog[Permission Catalog]
    end
    subgraph Tenant["租戶層"]
      Role[Role]
      RP[RolePermission]
      UR[UserRole]
      RM[RoleMapping]
    end
    Catalog -- 勾選 --> RP --> Role
    Role -- 指派 --> UR
    Role -- 對應 --> RM

    Tenant -- LoadPolicy --> Casbin[(Casbin Enforcer<br/>Redis adapter)]
    Casbin -- Check --> Middleware[CasbinRBAC Middleware]
```

- Permission **平台 seed 全局**（`cmd/permission-seed`）；租戶不可新增、只能勾選
- Role / RolePermission / UserRole **租戶獨立**；同名 role 跨租戶共存
- Role.Key 一旦建立 **不可改**（外部 IdP mapping 直接綁 key）
- 多 pod 同步：**Redis Pub/Sub 即時 + 5min cron 兜底**

---

## 核心概念

| 概念 | 簡述 | 關鍵欄位 |
|------|------|----------|
| **Permission** | 平台級權限節點（樹狀，dot notation） | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy |
| **Role** | 租戶內角色 | `(tenant_id, key)` unique；`is_system=true` 不可刪 |
| **RolePermission** | Role 勾選的 Permission | 自動補齊 parent permission |
| **UserRole** | 使用者被指派的角色 | `source` ∈ {manual / zitadel / ldap / scim} |
| **RoleMapping** | 外部 group → 內部 Role.Key | SyncFromX 用來翻譯 IdP claims |
| **Casbin Rule** | 物化後規則 | `[tenant, role.key, http_path, http_methods, perm.name]` |

### Permission Tree 範例

```
member.info.management            ← 分類（無 HTTP）
├── member.basic.info             ← 二級分類
│   ├── member.info.select        GET   /api/v1/members/me
│   └── member.info.update        PATCH /api/v1/members/me
└── member.admin.list             GET   /api/v1/members

permission.role.management        ← 分類
├── permission.role.read          GET    /api/v1/permissions/roles
├── permission.role.write         POST/PUT/DELETE /api/v1/permissions/roles*
└── permission.assign.write       POST/DELETE /api/v1/permissions/users/*/roles*
```

> 分類節點（無 `http_path`）**不會**寫入 Casbin policy；只是 UI 樹狀渲染 + parent closure 用。

---

## 目錄結構

```
internal/model/permission/
├── README.md
├── config/                # CasbinConfig / CacheConfig / ReloadConfig
├── domain/
│   ├── const.go           # BSON 欄位、Casbin / Role.Key 規則
│   ├── errors.go
│   ├── redis.go           # casbin / user_roles / role_perms key
│   ├── entity/            # Permission / Role / RolePermission / UserRole / RoleMapping
│   ├── enum/              # Status / PermissionType / RoleSource
│   ├── repository/        # 5 個 repo 介面 + Casbin adapter port
│   └── usecase/           # 7 個 usecase 介面 + DTO
├── repository/            # Mongo + Redis（casbin policy Set）
├── usecase/               # 7 個 atomic（含 permission_tree / rbac）
└── seed/
    ├── catalog.go         # embed + Apply + DefaultSystemRoles
    └── catalog.json       # 平台 seed
```

---

## 7 個 UseCase

| UseCase | 主要方法 |
|---------|---------|
| `PermissionUseCase` | `GetCatalogTree` / `List` / `UpsertCatalog` / `UpdateStatus` |
| `RoleUseCase` | `Create` / `Get` / `List` / `Update` / `Delete` |
| `RolePermissionUseCase` | `List` / `Replace`（含 parent closure + Pub/Sub reload） |
| `UserRoleUseCase` | `Assign` / `Revoke` / `List` / `ReplaceForSource` |
| `RoleMappingUseCase` | `Upsert` / `Delete` / `GetByExternal` / `List` |
| `AuthorizationQueryUseCase` | `Me` |
| `RBACUseCase` | `Check` / `LoadPolicy` / `LoadAllPolicies` / `BroadcastReload` / Pub/Sub 訂閱 |

---

## 資料儲存

### MongoDB

| Collection | 索引 |
|------------|------|
| `permissions` | `name` uniq、`parent`、`status`、`type` |
| `roles` | `(tenant_id, key)` uniq、`(tenant_id, is_system)` |
| `role_permissions` | `(tenant_id, role_id, permission_id)` uniq、`(tenant_id, permission_id)` |
| `user_roles` | `(tenant_id, uid, role_id)` uniq、`(tenant_id, role_id)`、`(tenant_id, uid, source)` |
| `role_mappings` | `(tenant_id, external_source, external_key)` uniq、`(tenant_id, internal_role_id)` |

啟動建索引：`permrepo.EnsureMongoIndexes`（已掛在 `cmd/mongo-index`）。

### Redis

| Key | 內容 | TTL | 由誰寫 |
|-----|------|-----|--------|
| `permission:casbin:rules:{tenant_id}` | Set of JSON Casbin rules | 永久 | `RBAC.LoadPolicy` / `BroadcastReload` |
| `perm:user_roles:{tenant_id}:{uid}` | role keys 快取（預留） | `Cache.UserRolesTTLSeconds` | — |
| `perm:role_perms:{tenant_id}:{role_id}` | permission names 快取（預留） | `Cache.RolePermsTTLSeconds` | — |
| (channel) `casbin:reload` | Pub/Sub payload `{tenant_id, ts}` | — | `RBAC.BroadcastReload` |

> Redis Set + JSON 是為了 SaveAll 用 pipelined `DEL + SADD` 原子更新；Pub/Sub 走獨立 go-redis client（go-zero 沒包 Subscribe，且 Subscribe 會佔住 conn）。

---

## 關鍵流程

### 1. RolePermission 全量取代（PUT /roles/:id/permissions）

```mermaid
sequenceDiagram
    API->>UC: Replace(tenantID, roleID, ids)
    UC->>Roles: GetByID（tenant check）
    UC->>Perms: GetAll（catalog 全表）
    UC->>UC: ids ⊆ catalog?
    UC->>UC: getFullParentPermissionIDs(ids)
    UC->>RP: SetForRole（DeleteMany + InsertMany 原子）
    UC->>RBAC: BroadcastReload(tenantID)
    UC-->>API: nil
```

### 2. SyncFromX（外部 IdP 同步）

```mermaid
sequenceDiagram
    Sync->>Map: GetByExternal(tenant, source, externalKey)
    Map-->>Sync: RoleMapping(internal_role_key)
    Note over Sync: 收齊 IdP 端 → keys
    Sync->>UC: ReplaceForSource(tenant, uid, source, [roleKeys])
    UC->>UC: 阻擋 source==manual（防誤洗）
    UC->>URR: DeleteMany source + BulkInsert
    UC->>RBAC: BroadcastReload(tenant)
```

### 3. LoadPolicy + Check

```mermaid
sequenceDiagram
    Trigger->>RBAC: LoadPolicy(tenantID)
    RBAC->>Roles: ListByTenant
    RBAC->>RP: ListByRoles(roleIDs)
    RBAC->>Perms: GetByIDs（unique perm ids）
    RBAC->>RBAC: 過濾 IsLeaf() && Status=open
    RBAC->>Enforcer: ClearPolicy + AddPolicies
    RBAC->>Adapter: SaveAll → Redis pipelined DEL+SADD

    MW->>RBAC: Check{tenant, uid, path, method}
    RBAC->>URR: ListByUser → ListByTenantAndIDs（過濾 status=open）
    loop role in roles（any-allow）
        RBAC->>Enforcer: EnforceEx
    end
    RBAC-->>MW: Allow / Deny（403）
```

### 4. Pub/Sub 多 Pod Reload

```mermaid
sequenceDiagram
    PodA->>PodA: RolePermission.Replace + LoadPolicy
    PodA->>Redis: PUBLISH casbin:reload {tenant, ts}
    Redis-->>PodB: 推 message
    Redis-->>PodC: 推 message
    PodB->>PodB: handleReload → LoadPolicy(tenant)
    PodC->>PodC: handleReload → LoadPolicy(tenant)
    Note over PodB,PodC: 2-3ms 內三個 pod 同步
```

> 兜底：每個 pod 可定時 `LoadAllPolicies`（5min cron，建議 svc 層或 cron-worker 觸發；本模組不內建）。

---

## Casbin 模型（`etc/rbac.conf`）

```ini
[request_definition]
r = tenant, role, path, method

[policy_definition]
p = tenant, role, path, methods, name

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods)
```

- `keyMatch2`：支援 `/api/v1/members/*`
- `regexMatch`：`GET|POST|PATCH` 多 method 同 policy
- 平台 Admin bypass 不寫在 matcher，由 middleware 預檢（保留 audit）

---

## ServiceContext 注入

```go
sc.PermissionCatalog        // PermissionUseCase
sc.PermissionRole           // RoleUseCase（含 system role 防呆）
sc.PermissionRolePermission // RolePermissionUseCase
sc.PermissionUserRole       // UserRoleUseCase
sc.PermissionRoleMapping    // RoleMappingUseCase
sc.PermissionAuthQuery      // AuthorizationQueryUseCase
sc.PermissionRBAC           // RBACUseCase（Mongo+Redis 全到位才有）
sc.PermissionRoleRepo       // 給 SCIM / SyncFromX 等下游使用
```

未啟用 Casbin 時 `PermissionRBAC == nil`，`Check()` 永遠 deny；middleware 視 `AllowMissingActor` 決定放行或拒絕。

---

## HTTP API（`/api/v1/permissions`）

| Method | Path | Middleware | 說明 |
|--------|------|------------|------|
| GET | `/catalog` | AuthJWT | 全局 Catalog（tree/list） |
| GET | `/me` | AuthJWT | 當前 user roles + permissions |
| GET | `/roles` | AuthJWT+Casbin | 租戶角色清單 |
| POST | `/roles` | AuthJWT+Casbin | 建立角色 |
| PATCH | `/roles/:id` | AuthJWT+Casbin | 更新 display_name / status |
| DELETE | `/roles/:id` | AuthJWT+Casbin | 刪角色（system / 仍有指派 → 拒絕） |
| GET | `/roles/:id/permissions` | AuthJWT+Casbin | 角色 permission 集合 |
| PUT | `/roles/:id/permissions` | AuthJWT+Casbin | 全量取代 + parent closure + reload |
| GET | `/users/:uid/roles` | AuthJWT+Casbin | 使用者 role |
| POST | `/users/:uid/roles` | AuthJWT+Casbin | 指派 |
| DELETE | `/users/:uid/roles/:role_id` | AuthJWT+Casbin | 撤銷 |
| GET / PUT / DELETE | `/role-mappings` | AuthJWT+Casbin | 外部映射 CRUD |
| POST | `/policy/reload` | AuthJWT+Casbin | 強制重載（單 tenant 或 `*`） |

完整 schema 見 `generate/api/permission.api`。

---

## 設定（`etc/gateway.dev.yaml`）

```yaml
Permission:
  Casbin:
    Enabled: false                # 預設關閉
    ModelPath: etc/rbac.conf
    PolicyAdapter: auto           # auto / redis / mongo
  Cache:
    UserRolesTTLSeconds: 300
    RolePermsTTLSeconds: 300
    CatalogTTLSeconds: 600
  Reload:
    Channel: casbin:reload
    DebounceMilliseconds: 200
    HeartbeatSeconds: 60
```

---

## CLI / 操作

```bash
# 建索引
make mongo-index

# Catalog seed（全平台）
go run ./cmd/permission-seed -f etc/gateway.dev.yaml

# Catalog + 為 tenant seed 5 個 system role
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001

# 只 reseed tenant role（catalog 已存在）
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog

# 強制全 pod 重載 policy
curl -X POST http://localhost:8888/api/v1/permissions/policy/reload \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"tenant_id": "*"}'
```

預設 5 個 system role：`tenant_owner` / `tenant_admin` / `member_manager` / `member` / `viewer`（`seed/catalog.go::DefaultSystemRoles`）。

---

## 設計約束（速查）

| 議題 | 決策 | 原因 |
|------|------|------|
| Permission `name` 改名 | 禁止 | 被 RolePermission / Casbin policy.name 引用；廢棄改 `status=close` |
| Role `key` 改名 | 禁止 | 外部 IdP mapping 直接綁 key |
| `is_system` 刪除 / 改 status | 拒絕 | 平台預設角色保留 |
| `manual` source ReplaceForSource | 拒絕 | 防 SyncFromX 誤洗手動指派 |
| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時 + reboot 不漏 |
| Pub/Sub client | 獨立 go-redis | go-zero 沒包 Subscribe |
| Catalog 改動 | seed CLI（idempotent） | catalog.json 是 SoT |

---

## 測試

```bash
# 單元
go test ./internal/model/permission/...

# 整合（需 Mongo + Redis）
make deps-up
go test -tags=integration ./internal/model/permission/...

# E2E（含 Casbin enforcement）
make e2e-casbin
```

E2E 細節：[`docs/e2e-testing.md`](../../../docs/e2e-testing.md)。
