# 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/`. ### 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.