Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,30 +158,31 @@ when that is what you mean.
### Permissions the identity provider defines itself

Administering this service is expressed as permissions like any other capability, so it can
be delegated to a role instead of being wired to a single group. These eleven always exist
be delegated to a role instead of being wired to a single group. These twelve always exist
and can never be created, deleted, or rekeyed, because the code checks for these exact
keys; which roles carry them is entirely up to you.

| Permission | Covers |
| ------------------------ | ------------------------------------------------------------ |
| `idp:people:read` | Searching the people registered here |
| `idp:users:read` | Browsing the user directory at `/users` |
| `idp:users:delete` | Permanently deleting someone's account |
| `idp:permissions:read` | Seeing permissions in the `/access` section |
| `idp:permissions:write` | Creating, changing, and deleting permissions |
| `idp:roles:read` | Seeing roles, what they grant, and who holds them |
| `idp:roles:write` | Creating, changing, and deleting roles |
| `idp:roles:assign` | Giving roles to people and taking them away |
| `idp:applications:read` | Seeing the OIDC applications at `/applications` |
| `idp:applications:write` | Registering and editing applications, and rotating secrets |
| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) |
| Permission | Covers |
| ------------------------ | ----------------------------------------------------------------- |
| `idp:people:read` | Searching the people registered here |
| `idp:users:read` | Browsing the user directory at `/users` |
| `idp:users:delete` | Permanently deleting someone's account |
| `idp:permissions:read` | Seeing permissions in the `/access` section |
| `idp:permissions:write` | Creating, changing, and deleting permissions |
| `idp:roles:read` | Seeing roles, what they grant, and who holds them |
| `idp:roles:write` | Creating, changing, and deleting roles |
| `idp:roles:assign` | Giving roles to people and taking them away |
| `idp:people:bind` | Choosing which roles the [People register](#people-roles) manages |
| `idp:applications:read` | Seeing the OIDC applications at `/applications` |
| `idp:applications:write` | Registering and editing applications, and rotating secrets |
| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) |

They use the permission hierarchy themselves: each `write` grants its `read`,
`idp:roles:assign` grants `idp:roles:read` and `idp:people:read` so an assigner can see the
role and find who to give it to, `idp:roles:write` also grants `idp:people:read`, `idp:users:read` grants `idp:people:read` because browsing everyone includes finding them, and `idp:roles:read` grants `idp:permissions:read` because a role is meaningless
without seeing the permissions it carries. A role with `idp:roles:write` therefore ends up
with four permissions and still cannot touch applications. `idp:roles:write` granting
`idp:roles:read`, `idp:roles:assign` granting `idp:roles:read`, `idp:permissions:write`
`idp:roles:read`, `idp:roles:assign` and `idp:people:bind` granting `idp:roles:read`, `idp:permissions:write`
granting `idp:permissions:read` and
`idp:users:delete` granting `idp:users:read` are fixed in code: they apply even if the stored edge is missing, and cannot be removed, because
changing either without seeing what already exists makes no sense. The other implications
Expand Down Expand Up @@ -338,6 +339,54 @@ During a sign-in the login page names the requesting application. The consent pa

Future consumers should use authorization code with PKCE, validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group.

### People roles

The admin dashboard's People register gives people predetermined roles: those bound, on the role's page (People register card), to what holding them means in People. A role's holders are admins of the Telegram groups (`admin`), Capo Admins (`capo`), or members or the Lead of a team (`team:<key>:member`, `team:<key>:lead`, where the key is the dashboard's team key: `it`, `hr`, `design`, `intl` or `events`). Binding a role needs `idp:people:bind`, and, as when editing a role, only Master Admin can bind or unbind a role granting permissions they don't hold. Managed roles, and roles that carry any `idp:*` permission directly, through a parent or through an implication, can't be bound, and saving a role or permission that would give a bound role one is refused. Bind only one role to each meaning: `trusted-admin` inherits `admin`, so binding it as `admin` too makes giving `admin` ambiguous (409).

`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, each with every permission it grants (`permissions`, through parent roles too) and its own binding followed by those it inherits (`bindings`), and everyone holding them by hand or through a parent role, with their name, directory email, Telegram ID and username, which of those roles they were given directly, and since when they hold each one (`since`, `YYYY-MM-DD`: the earliest of their current assignments conferring it). Unlike the access snapshot it includes roles that grant no permission. People without a login account are the backend's own: they never appear here.

`POST /api/internal/people-roles` (backend client, `idp:people:roles`) applies one People command `{ id, actor, sub, reason, changes }` on behalf of the dashboard user the backend authenticated. `sub` is always a login account. Each change names a desired state: `{ kind: "admin" | "capo", active }` or `{ kind: "team", team, role: "member" | "lead" | null }`. This IdP plans the concrete assignments against its own role graph and applies all of them or none: a concept with no bound role stays People's own and is skipped; it refuses with 409 when the mapping is ambiguous (not exactly one bound role to give), or when the requested state would still be held through another role (ending `admin` for a Capo Admin, say), rather than reporting a change that did not happen.

The backend only names the actor; this IdP resolves the actor's own access and refuses every assignment, before any write, unless:

- the actor isn't giving themselves a role (giving one up is allowed). When an invitee accepts an invite, the backend sends whoever made each assignment as the actor;
- the rule for the role's binding, and for every bound role it inherits, allows it (`PEOPLE_RULES` in `src/auth/people-binding.ts`):

| Binding | Who may give or take it back |
| ------------------- | ------------------------------------------------------------------------ |
| `admin` | `admin:access` + `people:write` |
| `capo` | `admin:access` + `people:appoint` (and, through `admin`, `people:write`) |
| `team:<key>:member` | `admin:access` + `people:write`, or the Lead of that team |
| `team:<key>:lead` | `admin:access` + `people:appoint` |

- unless Master Admin, the actor holds every permission the role grants.

The backend applies the same rules to assignments it keeps for records that aren't linked yet, which this IdP only sees when the record is linked: that is what each bound role's `permissions` and `bindings` are for.

The response is the person's bound roles after the command (`held`, `assigned`) and the bound role list. Each assignment is audited as `role.assign`/`role.unassign` with `via: "people"`, the reason and the command ID, and the command itself as `people.roles` under `people-command:<id>`: repeating an ID with the same command, for example after a lost response, applies nothing again and returns the current roles, and repeating it with a different command is refused with 409.

#### Deploying People roles

Deploy this before the backend and dashboard that use it, then (PoliNetworkOrg/auth#79):

1. Migrate (`0023_role_people_binding`). It adds the binding column and `idp:people:bind`, which only Master Admin holds until you grant it to a role.
2. Grant `idp:people:bind` to whoever should configure People, if anyone besides Master Admin.
3. Bind the roles. Team keys must match the backend's team list:

| Role | Binding |
| ------------------------------- | ----------------------------------------- |
| `admin` | `admin` |
| `capo-admin` | `capo` |
| `hr-member` / `hr-lead` | `team:hr:member` / `team:hr:lead` |
| `design-member` / `design-lead` | `team:design:member` / `team:design:lead` |
| `intl-member` / `intl-lead` | `team:intl:member` / `team:intl:lead` |
| `events-member` / `events-lead` | `team:events:member` / `team:events:lead` |

Leave `trusted-admin` unbound: it inherits `admin` and stays assigned by hand. `it-core` and `it-lead` carry `idp:*` permissions, so they can't be bound; the IT team needs plain team roles of its own (see #79).

4. Grant the internal scope `idp:people:roles` to the backend client on its application page. Reads work without it.
5. Send one role command from the backend for a test account, and check that the audit log shows `via: people`.

## MCP server

`/api/mcp` is an MCP server, so an AI agent such as Claude Code or Codex can read and change this identity provider for you: debug an application that cannot sign people in, register or edit applications, and set up roles, permissions and who holds them. It only works for people holding `idp:mcp`, and the agent can only do what that person could do on the website.
Expand Down Expand Up @@ -371,6 +420,7 @@ Every tool calls the same code as the matching page, which checks the permission
| `idp:roles:write` | `create_role`, `update_role`, `delete_role` |
| `idp:roles:assign` | `assign_role`, `unassign_role` |
| `idp:permissions:write` | `create_permission`, `update_permission`, `delete_permission` |
| `idp:people:bind` | `set_role_people_binding` |
| Master Admin | `list_audit_events` |

Deleting people is left to the website. Deletes ask the agent to repeat the name or key. New client secrets go back to the agent once, like on the page, so it can hand them over.
Expand Down
14 changes: 14 additions & 0 deletions drizzle/0023_role_people_binding.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE "role" ADD COLUMN "people_binding" text;--> statement-breakpoint
ALTER TABLE "role" ADD CONSTRAINT "role_people_binding_check" CHECK ("role"."people_binding" is null or (not "role"."managed" and "role"."people_binding" ~ '^(admin|capo|team:[a-z0-9-]{1,32}:(member|lead))$'));--> statement-breakpoint
-- Binding a role to People is delegated like every other administrative right. No role is
-- given it; Master Admin covers the new key through its wildcard.
INSERT INTO "permission" ("id", "key", "name", "description", "managed") VALUES
('managed-permission-idp-people-bind', 'idp:people:bind', 'Bind roles to People', 'Choose which roles the admin dashboard''s People register manages, and what holding each one means there.', true)
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
-- Binding a role means seeing what it grants.
INSERT INTO "permission_implication" ("permission_id", "implied_permission_id")
SELECT bind.id, implied.id
FROM "permission" bind
JOIN "permission" implied ON implied.key = 'idp:roles:read'
WHERE bind.key = 'idp:people:bind'
ON CONFLICT DO NOTHING;
Loading
Loading