forked from bai/curriculum-project-hub
130 lines
4.3 KiB
Markdown
130 lines
4.3 KiB
Markdown
# ADR 0019: Resolve Actors to Principal Sets for Team Permissions
|
|
|
|
## Status
|
|
|
|
Accepted.
|
|
|
|
## Context
|
|
|
|
ADR-0004 deliberately chose a Feishu Docs-like permission model:
|
|
`grant(resource, principal, role)` plus separate resource settings. The first
|
|
Hub implementation stored `principal` as an opaque string and the Feishu trigger
|
|
used the sender's `open_id` directly. That was enough for individual teacher
|
|
grants, but not for production collaboration:
|
|
|
|
- a curriculum team needs one grant to apply to many teachers;
|
|
- Feishu departments, user groups, and project chats should be grantable
|
|
principals;
|
|
- role-specific agent grants (`/review`, `/draft`) must compose with project
|
|
permissions consistently;
|
|
- audit/debug output must explain which principal and grant allowed or denied a
|
|
run.
|
|
|
|
## Decision
|
|
|
|
Introduce typed principals and resolve every actor to a principal set before
|
|
authorization.
|
|
|
|
### Principal types
|
|
|
|
`PrincipalType` is:
|
|
|
|
- `USER`: a Feishu user, identified by `feishuOpenId`.
|
|
- `TEAM`: a Hub-managed teacher team.
|
|
- `FEISHU_CHAT`: a Feishu chat/group.
|
|
- `FEISHU_DEPARTMENT`: a Feishu department.
|
|
- `FEISHU_USER_GROUP`: a Feishu user group.
|
|
- `APP`: an integration or bot principal.
|
|
|
|
`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
|
|
`principalId`. Legacy opaque principal strings are backfilled as
|
|
`USER/<old principal>`.
|
|
|
|
### Team membership
|
|
|
|
Hub teams are first-class. A user can be a direct active member of a team. A
|
|
team can also be bound to external Feishu principals: chat, department, or user
|
|
group. If an actor resolves to any bound external principal, the actor also
|
|
resolves to that Hub team.
|
|
|
|
Membership changes are effective immediately because authorization resolves the
|
|
principal set at request time.
|
|
|
|
### External Feishu principal sync
|
|
|
|
Feishu department, user group, and chat memberships are not scattered through
|
|
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
|
|
read by `PrincipalResolver`. The Feishu message context can also contribute the
|
|
current `FEISHU_CHAT` principal directly because receiving a group event is
|
|
already contextual proof that the actor is speaking in that group.
|
|
|
|
### Grant merging
|
|
|
|
Authorization evaluates all active grants matching any resolved principal for
|
|
the requested resource. There is no explicit deny in this ADR. Among matching
|
|
grants, the highest role wins:
|
|
|
|
```text
|
|
READ < EDIT < MANAGE
|
|
```
|
|
|
|
Action thresholds:
|
|
|
|
- project read requires `READ`.
|
|
- project edit requires `EDIT`.
|
|
- collaborator management requires `MANAGE`.
|
|
- agent trigger requires `EDIT`.
|
|
- normal agent cancel requires `MANAGE`.
|
|
|
|
`PermissionSettings` never grants access beyond grants. Settings only constrain
|
|
an already-granted capability. For `agentTrigger`:
|
|
|
|
- missing setting or `ROLE` means role-derived grants decide;
|
|
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
|
|
- `DISABLED` denies the action.
|
|
|
|
Other settings keep their existing string storage, but the same rule applies:
|
|
settings are policy constraints, not positive grants.
|
|
|
|
### Role-trigger composition
|
|
|
|
Role-trigger grants are a second gate after the project-level `agent.trigger`
|
|
gate:
|
|
|
|
1. The actor must pass project `agent.trigger` for the project.
|
|
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
|
|
the role is open for backward compatibility.
|
|
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
|
|
role is configured. The actor must match an active role grant by any resolved
|
|
principal.
|
|
|
|
This keeps `/review` restrictions orthogonal to ordinary edit access while
|
|
preventing a fully revoked role from silently reopening.
|
|
|
|
### Module seam
|
|
|
|
Callers use a single authorization module:
|
|
|
|
```ts
|
|
authorizer.can({
|
|
actor,
|
|
action,
|
|
resource,
|
|
roleId,
|
|
})
|
|
```
|
|
|
|
The caller does not know how users, teams, Feishu departments, user groups, or
|
|
chats expand. `PrincipalResolver` owns that implementation, and
|
|
`PermissionAuthorizer` owns grant/settings/role-trigger composition.
|
|
|
|
## Consequences
|
|
|
|
- Teacher teams become production-grade principals rather than UI sugar.
|
|
- Feishu external organization concepts can be synchronized and granted without
|
|
leaking Feishu-specific checks into the trigger path.
|
|
- A single audit decision can report actor, principal set, matched grant, and
|
|
matched role grant.
|
|
- Backward compatibility is explicit: old principal strings become user
|
|
principals, and unconfigured role grants remain open.
|