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

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

Expand All @@ -361,6 +361,8 @@ The backend only names the actor; this IdP resolves the actor's own access and r

- 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
Expand Down
19 changes: 18 additions & 1 deletion src/auth/people-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
MASTER_ADMIN_ROLE_KEY,
resolveAccess,
} from "./rbac";
import { planPeopleCommand } from "./people-role-command";
import { describePeopleBoundRoles, planPeopleCommand } from "./people-role-command";

function permission(key: string, managed = false): PermissionSummary {
return {
Expand Down Expand Up @@ -313,3 +313,20 @@ describe("planPeopleCommand with Capo Admins", () => {
).toThrow(/exactly one/);
});
});

it("describes each bound role with its effective permissions and inherited bindings", () => {
const described = describePeopleBoundRoles(catalog);
expect(described.find((entry) => entry.key === "capo-admin")).toEqual({
key: "capo-admin",
name: "capo-admin",
binding: "capo",
permissions: ["people:self", "tg:trusted"],
bindings: ["capo", "admin"],
});
expect(described.find((entry) => entry.key === "hr-lead")).toMatchObject({
permissions: ["admin:access", "people:read", "people:self", "people:write"],
bindings: ["team:hr:lead", "team:hr:member"],
});
// Roles People can't manage are left out, as everywhere else.
expect(described.map((entry) => entry.key)).not.toContain("sneaky");
});
39 changes: 35 additions & 4 deletions src/auth/people-role-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import { parsePeopleBinding, peopleBindingProblem } from "./people-binding";
import { expandRoleKeys, type RbacCatalog } from "./rbac";
import { effectiveRolePermissions, expandRoleKeys, type RbacCatalog } from "./rbac";

/**
* One People command from the backend. Each change names a desired state for one concept.
Expand Down Expand Up @@ -35,8 +35,22 @@ export const peopleCommandSchema = z
ctx.addIssue({ code: "custom", message: "Each concept may appear only once" });
});
export type PeopleCommand = z.infer<typeof peopleCommandSchema>;
/**
* A role bound to People, as the backend sees it. `permissions` and `bindings` let it apply
* `peopleRoleChangeProblem`'s rules itself to assignments it keeps before a record is linked,
* which this identity provider only sees at link time.
*/
export type PeopleBoundRole = {
key: string;
name: string;
binding: string;
/** Every permission the role grants, including through parent roles, sorted. */
permissions: string[];
/** Its own binding, then those of the roles it inherits. */
bindings: string[];
};
export type PeopleCommandResult = {
roles: { key: string; name: string; binding: string }[];
roles: PeopleBoundRole[];
held: string[];
assigned: string[];
};
Expand All @@ -49,11 +63,28 @@ export function peopleBoundRoles(catalog: RbacCatalog) {
);
}

/** The bound roles, with what `peopleRoleChangeProblem` checks for each of them. */
export function describePeopleBoundRoles(catalog: RbacCatalog): PeopleBoundRole[] {
return peopleBoundRoles(catalog).map((r) => {
const inherited = expandRoleKeys(catalog, [r.key]).flatMap((key) => {
const binding = catalog.roles.find((entry) => entry.key === key)?.peopleBinding;
return key !== r.key && parsePeopleBinding(binding) ? [binding!] : [];
});
return {
key: r.key,
name: r.name,
binding: r.peopleBinding!,
permissions: effectiveRolePermissions(catalog, r.key),
bindings: [r.peopleBinding!, ...inherited],
};
});
}

export function peopleCommandResult(catalog: RbacCatalog, assigned: string[]): PeopleCommandResult {
const roles = peopleBoundRoles(catalog);
const roles = describePeopleBoundRoles(catalog);
const keys = new Set(roles.map((r) => r.key));
return {
roles: roles.map((r) => ({ key: r.key, name: r.name, binding: r.peopleBinding! })),
roles,
held: expandRoleKeys(catalog, assigned).filter((key) => keys.has(key)),
assigned: assigned.filter((key) => keys.has(key)).sort(),
};
Expand Down
4 changes: 4 additions & 0 deletions src/auth/people-roles.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ describe.skipIf(!process.env.PEOPLE_ROLES_TEST_DATABASE_URL)(
expect(directory.roles.map((entry) => entry.binding)).toEqual(
expect.arrayContaining(["admin", "capo", `team:${team}:member`, `team:${team}:lead`]),
);
expect(directory.roles.find((entry) => entry.key === `${team}-capo`)).toMatchObject({
permissions: [],
bindings: ["capo", "admin"],
});
const today = new Date().toISOString().slice(0, 10);
const person = directory.users.find((entry) => entry.sub === target)!;
expect(person).not.toHaveProperty("azureId");
Expand Down
16 changes: 6 additions & 10 deletions src/auth/people-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import { logAuthorizationDenial } from "./denial-log";
import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject";
import { peopleRoleChangeProblem } from "./people-binding";
import {
type PeopleBoundRole,
type PeopleCommand,
type PeopleCommandResult,
PeopleCommandConflict,
peopleBoundRoles,
describePeopleBoundRoles,
peopleCommandResult,
planPeopleCommand,
} from "./people-role-command";
Expand All @@ -20,7 +21,7 @@ import { RbacError, assignedRoleKeys, auditSnapshot, readCatalog } from "./rbac-

export type PeopleDirectory = {
/** Every role People manages, with what holding it means. */
roles: { key: string; name: string; binding: string }[];
roles: PeopleBoundRole[];
/** Everyone holding at least one of those roles, by hand or through a parent role. */
users: {
sub: string;
Expand Down Expand Up @@ -50,14 +51,9 @@ export async function readPeopleDirectory(): Promise<PeopleDirectory> {
return db.transaction(
async (transaction) => {
const catalog = await readCatalog(transaction);
const bound = peopleBoundRoles(catalog);
const boundKeys = new Set(bound.map((entry) => entry.key));
const roles = bound.map((entry) => ({
key: entry.key,
name: entry.name,
binding: entry.peopleBinding!,
}));
if (!bound.length) return { roles, users: [] };
const roles = describePeopleBoundRoles(catalog);
const boundKeys = new Set(roles.map((entry) => entry.key));
if (!roles.length) return { roles, users: [] };

const assignments = await transaction
.select({ userId: userRole.userId, roleKey: role.key, assignedAt: userRole.assignedAt })
Expand Down