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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,15 @@ Future consumers should use authorization code with PKCE, validate token signatu

Master Admin can bind a hand-made role to the admin dashboard's People register on the role's page (People register card): its holders are admins of the Telegram groups (`admin`), 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`). 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.

`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, everyone holding them by hand, and everyone with a PoliNetwork Microsoft 365 account (whose roles may be empty), with their name, directory email, Microsoft 365 object ID, Telegram ID and username, and which of those roles they were given directly; unlike the access snapshot it includes roles that grant no permission. `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, where each change names a desired state: `{ kind: "admin", 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, 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 role and every bound role it inherits are allowed to the actor (admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`); the actor isn't giving themselves a role (giving one up is allowed); and, unless Master Admin, the actor holds every permission the role grants. 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. Grant `idp:people:roles` to the backend client on its application page to enable writes.
`GET /api/internal/people-roles` (backend client, `idp:access:read`) lists the bound roles, everyone holding them by hand, and everyone with a PoliNetwork Microsoft 365 account (whose roles may be empty), with their name, directory email, Microsoft 365 object ID, Telegram ID and username, which of those roles they were given directly, and since when they hold each (`since`, the earliest assignment that confers it, so People shows when a role started without storing it); unlike the access snapshot it includes roles that grant no permission. `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, where each change names a desired state: `{ kind: "admin", 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, 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 role and every bound role it inherits are allowed to the actor (admin and team member roles need `admin:access` and `people:write`, or for team members leading that team; Lead roles need `admin:access` and `people:appoint`); the actor isn't giving themselves a role (giving one up is allowed); and, unless Master Admin, the actor holds every permission the role grants. 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. Grant `idp:people:roles` to the backend client on its application page to enable writes.

### People before the first login

People can create a preliminary person in this IdP with only first and last name, optional email/phone/Telegram contacts, and bound admin/team roles. Migration `0024_people_registration.sql` stores the declared contacts separately from provider evidence. The subject holds its roles in Auth's existing role assignments but has no credentials, provider accounts or sessions, and is excluded from the login-account/signing picker.

The same backend-only People endpoint accepts idempotent `create-person`, `update-person` and `link-person` operations. Creation/contact editing require `admin:access` and `people:write`; assignments retain the existing delegation checks. Linking requires Master Admin and atomically transfers the preliminary subject's current bound roles to an existing login account, preserving that account's other roles and provider accounts. Names and declared contacts never grant access by themselves.

Directory responses include stable `registrationId`, `provisional`, declared contacts and review proposals based on verified app email or Telegram identity. Homonyms and conflicting identifiers are marked for review. The dashboard's Linking page is where a Master Admin approves or rejects the proposed identity; no automatic account merge occurs on login. Deploy this IdP and migration before the corresponding backend/admin changes.

## MCP server

Expand Down
16 changes: 16 additions & 0 deletions drizzle/0024_people_registration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE "people_registration" (
"id" text PRIMARY KEY NOT NULL,
"first_name" text NOT NULL,
"last_name" text NOT NULL,
"email" text,
"phone" text,
"telegram_id" text,
"telegram_username" text,
"linked_user_id" text,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_id_user_id_fk" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "people_registration" ADD CONSTRAINT "people_registration_linked_user_id_user_id_fk" FOREIGN KEY ("linked_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "people_registration_linked_user_idx" ON "people_registration" USING btree ("linked_user_id");
Loading