diff --git a/.changeset/permission-commands.md b/.changeset/permission-commands.md new file mode 100644 index 0000000..b366924 --- /dev/null +++ b/.changeset/permission-commands.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +Add `seamless permissions` and `seamless roles` to manage the permission catalogue, organization roles, and the permissions each instance or organization role grants. `roles set` replaces a role's whole mapping and asks before taking a permission away. Every command takes `--json`. They need auth server v0.23 or later, and an older instance gets a message saying so. diff --git a/AGENTS.md b/AGENTS.md index 9bbd12c..1ca6ad4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ guidance may extend them but must not contradict them. - Run from source: `npm run dev -- ` (`tsx`); or after building, `node dist/index.js ` - Commands: `init [name]`, `templates`, `check`, `verify [flags]`, `apps`, and the instance-management commands `profile`, `login`, `whoami`, `logout`, - `sessions`, `config`, `users`, `org` (all dispatched from `src/index.ts`) + `sessions`, `config`, `users`, `org`, `permissions`, `roles` (all dispatched from `src/index.ts`) The entry point is [src/index.ts](src/index.ts), which dispatches to a command module in `src/commands/`. @@ -112,7 +112,7 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m - **verify** ([src/commands/verify.ts](src/commands/verify.ts)) runs the conformance harness (below). - **instance management** — `profile` (targets, plus `profile login`), `logout`/`whoami`, `sessions`, `config` (system config + OAuth providers), - `users`, and `org` all talk to a running instance and are authenticated by the + `users`, `org`, `permissions`, and `roles` all talk to a running instance and are authenticated by the stored session. - **help** — `seamless --help`, `seamless -h/--help`, and `seamless help ` all render from the single registry in [src/commands/helpTopics.ts](src/commands/helpTopics.ts) diff --git a/README.md b/README.md index 1957bfe..4bc4273 100644 --- a/README.md +++ b/README.md @@ -770,6 +770,28 @@ the step social engineering aims at, so the instance records how their identity `--proofing` is `in_person` or `remote_exception`, `--evidence-ref` is a ticket or case number for the audit trail (never personal data), and `--approver` names who approved a remote exception. +### Admin: permissions and roles + +Define what roles can do (requires an admin role and auth server v0.23 or later; an older instance +gets a message saying so). Permissions are a catalogue of exact names such as `invoices:read`. +Instance roles are the instance's `available_roles`; organization roles are shared by every +organization, with `owner`, `admin` and `member` built in. + +```bash +seamless permissions create invoices:read --description "Read invoices" +seamless permissions list +seamless permissions delete invoices:read # confirms; refused while a role grants it + +seamless roles create billing --permissions invoices:read # an organization role +seamless roles set organization billing invoices:read invoices:write +seamless roles set instance support --none # confirms before taking any away +seamless roles list --kind organization --json +seamless roles delete billing # confirms; refused while a member holds it +``` + +`roles set` replaces everything the role grants. A change reaches a holder on their next access +token. + ### Admin: OpenID Provider clients and resources Register the OIDC clients that sign users in through the OpenID Provider, and the APIs @@ -804,7 +826,7 @@ weaker than registered keys and need `allow_client_secrets` on the instance. From auth server v0.20, every admin write needs a fresh step-up on the session: `config set`, `config apply`, `config oauth-providers add|update|refresh|remove`, `config hosted-ui set|reset`, `users delete`, `users prepare-device-replacement`, the `org` and `org members` writes, the `clients` and -`resources` writes, and `migrate`. When the +`resources` writes, the `permissions` and `roles` writes, and `migrate`. When the instance asks for one, the CLI prompts for the code from your authenticator app, verifies it on the same session (`POST /totp/verify-mfa`), and retries the write once. diff --git a/src/commands/adminShared.ts b/src/commands/adminShared.ts index 933a27c..9f23fa1 100644 --- a/src/commands/adminShared.ts +++ b/src/commands/adminShared.ts @@ -1,4 +1,5 @@ import kleur from "kleur"; +import type { z } from "zod"; import { ReauthRequiredError } from "../core/authClient.js"; import { AdminApiError, PermissionError } from "../core/admin.js"; import { extractFlag } from "../core/args.js"; @@ -18,6 +19,31 @@ export function reportAdminError(err: unknown): never { throw err; } +export function fail(message: string): never { + console.error(kleur.red(message)); + process.exit(1); +} + +/** The arguments that are not flags, once every flag with a value is extracted. */ +export function positionals(args: string[]): string[] { + return args.filter((arg) => !arg.startsWith("-")); +} + +/** Checks input against the request schema the API uses, so a bad value fails before the round trip. */ +export function validate(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (result.success) return result.data; + + fail( + result.error.issues + .map((issue) => { + const where = issue.path.join("."); + return where ? `${where}: ${issue.message}` : issue.message; + }) + .join("\n"), + ); +} + export function parseList(value?: string): string[] | undefined { if (value === undefined) return undefined; return value diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index 3086a70..2346607 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -776,6 +776,63 @@ org members remove [--force] }, ], }, + { + name: "permissions", + usage: ["seamless permissions "], + sections: [ + { + heading: "permissions ", + body: `The instance's permission catalogue (requires an admin role, and auth server +v0.23 or later). Roles grant permissions; see seamless roles. Writes need a +fresh step-up, as for org. + +permissions list [--json] +permissions create [--description ] [--json] + • A name is letters, digits and hyphens in colon-separated segments, such + as invoices:read. Names match exactly: invoices:write does not satisfy + invoices:read, and there are no wildcards +permissions update (--description | --no-description) [--json] +permissions delete [--force] + • Refused while a role still grants it. The error names those roles + +--force + • Skips the confirmation on delete (--yes and -y are accepted aliases)`, + }, + ], + }, + { + name: "roles", + usage: ["seamless roles "], + sections: [ + { + heading: "roles ", + body: `Instance and organization roles and the permissions they grant (requires an +admin role, and auth server v0.23 or later). Writes need a fresh step-up, as +for org. + +Instance roles are the instance's available_roles (seamless config roles) +and reach the access token as roles. Organization roles are a catalogue +shared by every organization, held on a membership, and reach the token as +org_roles. owner, admin and member are built in. A change reaches a holder +on their next access token. + +roles list [--kind instance|organization] [--json] +roles set (... | --none) [--force] [--json] + • Replaces everything the role grants. Every permission must exist + • Asks before taking a permission away, unless --force +roles create [--description ] [--permissions a,b] [--json] + • Creates an organization role +roles update (--description | --no-description) [--json] +roles delete [--force] + • Deletes an organization role. Refused for a built-in role and for one + a membership still holds + +--force + • Skips the confirmation on set and delete (--yes and -y are accepted + aliases)`, + }, + ], + }, { name: "clients", usage: [ diff --git a/src/commands/permissions.test.ts b/src/commands/permissions.test.ts new file mode 100644 index 0000000..406ab89 --- /dev/null +++ b/src/commands/permissions.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + AdminApiError, + createPermission, + deletePermission, + listPermissions, + PERMISSIONS_UNSUPPORTED_MESSAGE, + updatePermission, +} from "../core/admin.js"; +import { apiPermission } from "../core/admin.fixtures.js"; +import { runPermissions } from "./permissions.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/tty.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireInteractive: vi.fn() }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listPermissions: vi.fn(), + createPermission: vi.fn(), + updatePermission: vi.fn(), + deletePermission: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function errors(): string { + return errorSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function lastJson(): unknown { + return JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); +} + +async function exits(args: string[]): Promise { + await expect(runPermissions(args)).rejects.toBeInstanceOf(ExitError); +} + +describe("runPermissions", () => { + it("prints usage on an unknown or missing subcommand", async () => { + await exits(["bogus"]); + expect(errors()).toContain("Unknown permissions subcommand: bogus"); + expect(logs()).toContain("Usage: seamless permissions "); + + await exits([]); + expect(errors()).toContain("(none)"); + }); + + it("passes --profile to the client", async () => { + vi.mocked(listPermissions).mockResolvedValue({ permissions: [], total: 0 }); + await runPermissions(["list", "--profile", "staging"]); + expect(createAuthClient).toHaveBeenCalledWith({ profileFlag: "staging" }); + }); + + it("says an instance before v0.23 has no catalogue, and exits 1", async () => { + vi.mocked(listPermissions).mockRejectedValue( + new AdminApiError(PERMISSIONS_UNSUPPORTED_MESSAGE), + ); + await exits(["list"]); + expect(errors()).toContain("Seamless Auth API v0.23 or later"); + }); +}); + +describe("permissions list", () => { + it("lists names with their descriptions and the count", async () => { + vi.mocked(listPermissions).mockResolvedValue({ + permissions: [ + apiPermission({ description: "Read invoices" }), + apiPermission({ id: "p2", name: "invoices:write" }), + ], + total: 2, + }); + await runPermissions(["list"]); + expect(logs()).toContain("invoices:read"); + expect(logs()).toContain("Read invoices"); + expect(logs()).toContain("invoices:write"); + expect(logs()).toContain("2 permissions."); + }); + + it("says so when there are none, and prints JSON on request", async () => { + vi.mocked(listPermissions).mockResolvedValue({ permissions: [], total: 0 }); + await runPermissions(["list"]); + expect(logs()).toContain("No permissions."); + + vi.mocked(listPermissions).mockResolvedValue({ + permissions: [apiPermission()], + total: 1, + }); + await runPermissions(["list"]); + expect(logs()).toContain("1 permission."); + + await runPermissions(["list", "--json"]); + expect(lastJson()).toEqual([apiPermission()]); + }); +}); + +describe("permissions create", () => { + it("creates a permission with a description", async () => { + vi.mocked(createPermission).mockResolvedValue(apiPermission({ description: "Read" })); + await runPermissions(["create", "invoices:read", "--description", "Read"]); + expect(createPermission).toHaveBeenCalledWith(fakeClient, { + name: "invoices:read", + description: "Read", + }); + expect(logs()).toContain("Created permission invoices:read."); + + await runPermissions(["create", "invoices:read", "--json"]); + expect(createPermission).toHaveBeenLastCalledWith(fakeClient, { name: "invoices:read" }); + expect(lastJson()).toMatchObject({ name: "invoices:read" }); + }); + + it("refuses a bad name or an empty description before asking the API", async () => { + await exits(["create", "invoices read"]); + expect(errors()).toContain("invoices read is not a valid permission name."); + + await exits(["create", "invoices:read", "--description", " "]); + expect(errors()).toContain("description:"); + expect(createPermission).not.toHaveBeenCalled(); + }); + + it("needs a name", async () => { + await exits(["create"]); + expect(errors()).toContain("Usage: seamless permissions create "); + }); +}); + +describe("permissions update", () => { + it("sets or clears the description", async () => { + vi.mocked(updatePermission).mockResolvedValue(apiPermission({ description: "New" })); + await runPermissions(["update", "invoices:read", "--description", "New"]); + expect(updatePermission).toHaveBeenCalledWith(fakeClient, "invoices:read", { + description: "New", + }); + expect(logs()).toContain("Updated permission invoices:read."); + + vi.mocked(updatePermission).mockResolvedValue(apiPermission()); + await runPermissions(["update", "invoices:read", "--no-description", "--json"]); + expect(updatePermission).toHaveBeenLastCalledWith(fakeClient, "invoices:read", { + description: null, + }); + expect(lastJson()).toMatchObject({ description: null }); + }); + + it("needs a name and exactly one change", async () => { + await exits(["update"]); + expect(errors()).toContain("Usage: seamless permissions update "); + + await exits(["update", "invoices:read"]); + expect(errors()).toContain("Nothing to update."); + + await exits(["update", "invoices:read", "--description", "x", "--no-description"]); + expect(errors()).toContain("not both"); + expect(updatePermission).not.toHaveBeenCalled(); + }); +}); + +describe("permissions delete", () => { + it("confirms, then deletes", async () => { + await runPermissions(["delete", "invoices:read"]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("invoices:read") }), + ); + expect(deletePermission).toHaveBeenCalledWith(fakeClient, "invoices:read"); + expect(logs()).toContain("Deleted permission invoices:read."); + }); + + it("skips the question with --force", async () => { + await runPermissions(["delete", "invoices:read", "--force"]); + expect(confirm).not.toHaveBeenCalled(); + expect(deletePermission).toHaveBeenCalled(); + }); + + it("does nothing when declined", async () => { + vi.mocked(confirm).mockResolvedValue(false); + await runPermissions(["delete", "invoices:read"]); + expect(deletePermission).not.toHaveBeenCalled(); + expect(logs()).toContain("Cancelled."); + }); + + it("needs a valid name", async () => { + await exits(["delete"]); + expect(errors()).toContain("Usage: seamless permissions delete "); + + await exits(["delete", "bad/name"]); + expect(errors()).toContain("not a valid permission name"); + }); +}); diff --git a/src/commands/permissions.ts b/src/commands/permissions.ts new file mode 100644 index 0000000..f706c95 --- /dev/null +++ b/src/commands/permissions.ts @@ -0,0 +1,150 @@ +import { + CreatePermissionRequestSchema, + PermissionNameSchema, + UpdatePermissionRequestSchema, +} from "@seamless-auth/types"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + createPermission, + deletePermission, + listPermissions, + updatePermission, + type AdminPermission, +} from "../core/admin.js"; +import { confirmDestructive, hasForceFlag } from "../core/confirmAction.js"; +import { fail, positionals, reportAdminError, validate } from "./adminShared.js"; + +const USAGE = "Usage: seamless permissions "; + +export async function runPermissions(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await permissionsList(client, rest); + return; + case "create": + await permissionsCreate(client, rest); + return; + case "update": + await permissionsUpdate(client, rest); + return; + case "delete": + await permissionsDelete(client, rest); + return; + default: + console.error(kleur.red(`Unknown permissions subcommand: ${sub ?? "(none)"}`)); + console.log(USAGE); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +function printPermission(permission: AdminPermission): void { + console.log( + kleur.bold(permission.name) + + (permission.description ? kleur.dim(` ${permission.description}`) : ""), + ); +} + +/** + * Fails on a name the API would refuse, saying what a name may hold. The schema's + * own message is the raw pattern, which reads as noise. + */ +export function checkName(name: string, what: string): string { + const result = PermissionNameSchema.safeParse(name); + if (result.success) return result.data; + fail( + `${name} is not a valid ${what} name. Use letters, digits and hyphens, in segments separated by colons (such as invoices:read), up to 80 characters.`, + ); +} + +function permissionName(rest: string[], usage: string): string { + const name = positionals(rest)[0]; + if (!name) fail(usage); + return checkName(name, "permission"); +} + +async function permissionsList(client: AuthClient, rest: string[]): Promise { + const { permissions, total } = await listPermissions(client); + + if (rest.includes("--json")) { + console.log(JSON.stringify(permissions, null, 2)); + return; + } + if (permissions.length === 0) { + console.log(kleur.dim("No permissions.")); + return; + } + for (const permission of permissions) printPermission(permission); + console.log(kleur.dim(`${total} permission${total === 1 ? "" : "s"}.`)); +} + +async function permissionsCreate(client: AuthClient, args: string[]): Promise { + const description = extractFlag(args, "description"); + const name = positionals(description.rest)[0]; + if (!name) { + fail("Usage: seamless permissions create [--description ] [--json]"); + } + + const body = validate(CreatePermissionRequestSchema, { + name: checkName(name, "permission"), + ...(description.value !== undefined ? { description: description.value } : {}), + }); + const permission = await createPermission(client, body); + if (description.rest.includes("--json")) { + console.log(JSON.stringify(permission, null, 2)); + return; + } + console.log(kleur.green(`Created permission ${permission.name}.`)); + printPermission(permission); +} + +async function permissionsUpdate(client: AuthClient, args: string[]): Promise { + const usage = + "Usage: seamless permissions update (--description | --no-description) [--json]"; + const description = extractFlag(args, "description"); + const clear = description.rest.includes("--no-description"); + const name = permissionName(description.rest, usage); + + if (description.value === undefined && !clear) { + fail("Nothing to update. Pass --description , or --no-description to clear it."); + } + if (description.value !== undefined && clear) { + fail("Pass --description or --no-description, not both."); + } + + const body = validate(UpdatePermissionRequestSchema, { + description: clear ? null : description.value, + }); + const permission = await updatePermission(client, name, body); + if (description.rest.includes("--json")) { + console.log(JSON.stringify(permission, null, 2)); + return; + } + console.log(kleur.green(`Updated permission ${permission.name}.`)); + printPermission(permission); +} + +async function permissionsDelete(client: AuthClient, rest: string[]): Promise { + const name = permissionName(rest, "Usage: seamless permissions delete [--force]"); + + const confirmed = await confirmDestructive({ + message: `Delete permission ${name}? This cannot be undone.`, + force: hasForceFlag(rest), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + + await deletePermission(client, name); + console.log(kleur.green(`Deleted permission ${name}.`)); +} diff --git a/src/commands/roles.test.ts b/src/commands/roles.test.ts new file mode 100644 index 0000000..8739dc4 --- /dev/null +++ b/src/commands/roles.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { confirm, isCancel } from "@clack/prompts"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + createOrganizationRole, + deleteOrganizationRole, + listRoles, + setRolePermissions, + updateOrganizationRole, +} from "../core/admin.js"; +import { apiRole } from "../core/admin.fixtures.js"; +import { runRoles } from "./roles.js"; + +vi.mock("@clack/prompts", () => ({ + confirm: vi.fn(), + isCancel: vi.fn(), +})); + +vi.mock("../core/tty.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireInteractive: vi.fn() }; +}); + +vi.mock("../core/authClient.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAuthClient: vi.fn() }; +}); + +vi.mock("../core/admin.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listRoles: vi.fn(), + setRolePermissions: vi.fn(), + createOrganizationRole: vi.fn(), + updateOrganizationRole: vi.fn(), + deleteOrganizationRole: vi.fn(), + }; +}); + +class ExitError extends Error { + code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +const fakeClient: AuthClient = { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), +}; + +let logSpy: ReturnType; +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAuthClient).mockResolvedValue(fakeClient); + vi.mocked(isCancel).mockReturnValue(false); + vi.mocked(confirm).mockResolvedValue(true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function logs(): string { + return logSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function errors(): string { + return errorSpy.mock.calls.map((c) => String(c[0])).join("\n"); +} + +function lastJson(): unknown { + return JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])); +} + +async function exits(args: string[]): Promise { + await expect(runRoles(args)).rejects.toBeInstanceOf(ExitError); +} + +const ROLES = [ + apiRole({ name: "admin", kind: "instance", permissions: ["invoices:read"] }), + apiRole({ name: "owner", builtIn: true, description: "Runs the organization" }), + apiRole({ permissions: ["invoices:read", "invoices:write"] }), +]; + +describe("runRoles", () => { + it("prints usage on an unknown or missing subcommand", async () => { + await exits(["bogus"]); + expect(errors()).toContain("Unknown roles subcommand: bogus"); + expect(logs()).toContain("Usage: seamless roles "); + + await exits([]); + expect(errors()).toContain("(none)"); + }); +}); + +describe("roles list", () => { + it("groups roles by kind with what they grant", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + await runRoles(["list"]); + const out = logs(); + expect(out.indexOf("Instance roles")).toBeLessThan(out.indexOf("Organization roles")); + expect(out).toContain("(built in)"); + expect(out).toContain("Runs the organization"); + expect(out).toContain("invoices:read, invoices:write"); + expect(out).toContain("none"); + }); + + it("filters by kind, and prints JSON on request", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + await runRoles(["list", "--kind", "instance", "--json"]); + expect(lastJson()).toEqual([ROLES[0]]); + + await runRoles(["list", "--kind", "organization"]); + expect(logs()).not.toContain("Instance roles"); + }); + + it("says so when there are none", async () => { + vi.mocked(listRoles).mockResolvedValue([]); + await runRoles(["list"]); + expect(logs()).toContain("No roles."); + }); + + it("refuses an unknown kind", async () => { + await exits(["list", "--kind", "team"]); + expect(errors()).toContain("instance or organization, not team"); + }); +}); + +describe("roles set", () => { + it("adds permissions without asking", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + vi.mocked(setRolePermissions).mockResolvedValue(ROLES[2]); + await runRoles([ + "set", + "organization", + "billing", + "invoices:read,invoices:write", + "invoices:read", + ]); + expect(confirm).not.toHaveBeenCalled(); + expect(setRolePermissions).toHaveBeenCalledWith( + fakeClient, + "organization", + "billing", + ["invoices:read", "invoices:write"], + ); + expect(logs()).toContain("Set the permissions of the organization role billing."); + }); + + it("asks before taking a permission away", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + vi.mocked(setRolePermissions).mockResolvedValue(apiRole({ permissions: ["invoices:read"] })); + await runRoles(["set", "organization", "billing", "invoices:read", "--json"]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Take invoices:write away from the organization role billing"), + }), + ); + expect(lastJson()).toMatchObject({ permissions: ["invoices:read"] }); + }); + + it("clears with --none, and stops when declined", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + vi.mocked(confirm).mockResolvedValue(false); + await runRoles(["set", "organization", "billing", "--none"]); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("lose them") }), + ); + expect(setRolePermissions).not.toHaveBeenCalled(); + expect(logs()).toContain("Cancelled."); + }); + + it("skips the question with --force", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + vi.mocked(setRolePermissions).mockResolvedValue(apiRole({ kind: "instance", name: "admin" })); + await runRoles(["set", "instance", "admin", "--none", "--force"]); + expect(confirm).not.toHaveBeenCalled(); + expect(setRolePermissions).toHaveBeenCalledWith(fakeClient, "instance", "admin", []); + }); + + it("leaves a role it cannot find to the API to refuse", async () => { + vi.mocked(listRoles).mockResolvedValue(ROLES); + vi.mocked(setRolePermissions).mockResolvedValue(apiRole({ name: "ghost" })); + await runRoles(["set", "organization", "ghost", "--none"]); + expect(confirm).not.toHaveBeenCalled(); + expect(setRolePermissions).toHaveBeenCalled(); + }); + + it("needs a kind, a role, and permissions or --none", async () => { + await exits(["set"]); + expect(errors()).toContain("Usage: seamless roles set "); + + await exits(["set", "team", "billing", "a"]); + expect(errors()).toContain("not team"); + + await exits(["set", "organization"]); + expect(errors()).toContain("Usage: seamless roles set"); + + await exits(["set", "organization", "billing"]); + expect(errors()).toContain("or pass --none to grant nothing"); + + await exits(["set", "organization", "billing", "a", "--none"]); + expect(errors()).toContain("not both"); + + await exits(["set", "organization", "billing", "bad name"]); + expect(errors()).toContain("bad name is not a valid permission name."); + expect(listRoles).not.toHaveBeenCalled(); + }); +}); + +describe("roles create", () => { + it("creates an organization role with permissions", async () => { + vi.mocked(createOrganizationRole).mockResolvedValue(apiRole({ permissions: ["a"] })); + await runRoles([ + "create", + "billing", + "--description", + "Pays", + "--permissions", + "a, a", + ]); + expect(createOrganizationRole).toHaveBeenCalledWith(fakeClient, { + name: "billing", + description: "Pays", + permissions: ["a"], + }); + expect(logs()).toContain("Created organization role billing."); + + await runRoles(["create", "billing", "--json"]); + expect(createOrganizationRole).toHaveBeenLastCalledWith(fakeClient, { name: "billing" }); + expect(lastJson()).toMatchObject({ name: "billing" }); + }); + + it("checks names before asking the API", async () => { + await exits(["create"]); + expect(errors()).toContain("Usage: seamless roles create "); + + await exits(["create", "bad role"]); + expect(errors()).toContain("bad role is not a valid role name."); + + await exits(["create", "billing", "--permissions", "ok,not ok"]); + expect(errors()).toContain("not ok is not a valid permission name."); + expect(createOrganizationRole).not.toHaveBeenCalled(); + }); +}); + +describe("roles update", () => { + it("sets or clears the description", async () => { + vi.mocked(updateOrganizationRole).mockResolvedValue(apiRole({ description: "Pays" })); + await runRoles(["update", "billing", "--description", "Pays"]); + expect(updateOrganizationRole).toHaveBeenCalledWith(fakeClient, "billing", { + description: "Pays", + }); + expect(logs()).toContain("Updated organization role billing."); + + await runRoles(["update", "billing", "--no-description", "--json"]); + expect(updateOrganizationRole).toHaveBeenLastCalledWith(fakeClient, "billing", { + description: null, + }); + }); + + it("needs a name and exactly one change", async () => { + await exits(["update"]); + expect(errors()).toContain("Usage: seamless roles update "); + + await exits(["update", "billing"]); + expect(errors()).toContain("Nothing to update."); + + await exits(["update", "billing", "--description", "x", "--no-description"]); + expect(errors()).toContain("not both"); + }); +}); + +describe("roles delete", () => { + it("confirms, then deletes", async () => { + await runRoles(["delete", "billing"]); + expect(confirm).toHaveBeenCalled(); + expect(deleteOrganizationRole).toHaveBeenCalledWith(fakeClient, "billing"); + expect(logs()).toContain("Deleted organization role billing."); + }); + + it("skips the question with --force, and stops when declined", async () => { + await runRoles(["delete", "billing", "-y"]); + expect(confirm).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + vi.mocked(confirm).mockResolvedValue(false); + await runRoles(["delete", "billing"]); + expect(deleteOrganizationRole).not.toHaveBeenCalled(); + expect(logs()).toContain("Cancelled."); + }); + + it("needs a name", async () => { + await exits(["delete"]); + expect(errors()).toContain("Usage: seamless roles delete "); + }); +}); diff --git a/src/commands/roles.ts b/src/commands/roles.ts new file mode 100644 index 0000000..4940ba5 --- /dev/null +++ b/src/commands/roles.ts @@ -0,0 +1,229 @@ +import { + CreateOrganizationRoleRequestSchema, + RoleKindSchema, + SetRolePermissionsRequestSchema, + UpdateOrganizationRoleRequestSchema, + type RoleKind, +} from "@seamless-auth/types"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + createOrganizationRole, + deleteOrganizationRole, + listRoles, + setRolePermissions, + updateOrganizationRole, + type AdminRole, +} from "../core/admin.js"; +import { confirmDestructive, hasForceFlag } from "../core/confirmAction.js"; +import { + fail, + parseList, + positionals, + reportAdminError, + validate, +} from "./adminShared.js"; +import { checkName } from "./permissions.js"; + +const USAGE = "Usage: seamless roles "; + +const KIND_LABEL: Record = { + instance: "Instance roles", + organization: "Organization roles", +}; + +export async function runRoles(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await rolesList(client, rest); + return; + case "set": + await rolesSet(client, rest); + return; + case "create": + await rolesCreate(client, rest); + return; + case "update": + await rolesUpdate(client, rest); + return; + case "delete": + await rolesDelete(client, rest); + return; + default: + console.error(kleur.red(`Unknown roles subcommand: ${sub ?? "(none)"}`)); + console.log(USAGE); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +function parseKind(value: string | undefined, usage: string): RoleKind { + if (value === undefined) fail(usage); + const kind = RoleKindSchema.safeParse(value); + if (!kind.success) fail(`The role kind is instance or organization, not ${value}.`); + return kind.data; +} + +function printRole(role: AdminRole): void { + console.log( + kleur.bold(role.name) + + (role.builtIn ? kleur.dim(" (built in)") : "") + + (role.description ? kleur.dim(` ${role.description}`) : ""), + ); + console.log( + kleur.dim(" permissions: ") + + (role.permissions.length ? role.permissions.join(", ") : kleur.dim("none")), + ); +} + +function printJsonOr(rest: string[], role: AdminRole, message: string): void { + if (rest.includes("--json")) { + console.log(JSON.stringify(role, null, 2)); + return; + } + console.log(kleur.green(message)); + printRole(role); +} + +async function rolesList(client: AuthClient, args: string[]): Promise { + const kindFlag = extractFlag(args, "kind"); + const kind = + kindFlag.value === undefined + ? undefined + : parseKind(kindFlag.value, "Usage: seamless roles list [--kind instance|organization] [--json]"); + + const roles = (await listRoles(client)).filter((role) => !kind || role.kind === kind); + + if (kindFlag.rest.includes("--json")) { + console.log(JSON.stringify(roles, null, 2)); + return; + } + if (roles.length === 0) { + console.log(kleur.dim("No roles.")); + return; + } + + for (const group of RoleKindSchema.options) { + const inGroup = roles.filter((role) => role.kind === group); + if (inGroup.length === 0) continue; + console.log(kleur.underline(KIND_LABEL[group])); + for (const role of inGroup) printRole(role); + } +} + +// The API replaces the whole mapping, so a permission left off is taken away from +// every holder on their next token. Reading the current mapping first lets the +// command say what is about to go, and ask before it does. +async function rolesSet(client: AuthClient, args: string[]): Promise { + const usage = + "Usage: seamless roles set (... | --none) [--force] [--json]"; + const none = args.includes("--none"); + const [kindArg, name, ...listed] = positionals(args); + const kind = parseKind(kindArg, usage); + if (!name) fail(usage); + + const permissions = listed.flatMap((item) => parseList(item) ?? []); + if (permissions.length === 0 && !none) { + fail( + `Name the permissions ${name} should grant, or pass --none to grant nothing. The list replaces what it grants now.`, + ); + } + if (permissions.length > 0 && none) { + fail("Pass permissions or --none, not both."); + } + for (const permission of permissions) checkName(permission, "permission"); + const body = validate(SetRolePermissionsRequestSchema, { + permissions: [...new Set(permissions)], + }); + + const current = (await listRoles(client)).find( + (role) => role.kind === kind && role.name === name, + ); + const removed = current + ? current.permissions.filter((p) => !body.permissions.includes(p)) + : []; + + if (removed.length > 0) { + const confirmed = await confirmDestructive({ + message: `Take ${removed.join(", ")} away from the ${kind} role ${name}? Holders lose ${removed.length === 1 ? "it" : "them"} on their next access token.`, + force: hasForceFlag(args), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + } + + const role = await setRolePermissions(client, kind, name, body.permissions); + printJsonOr(args, role, `Set the permissions of the ${kind} role ${name}.`); +} + +async function rolesCreate(client: AuthClient, args: string[]): Promise { + const description = extractFlag(args, "description"); + const permissionsFlag = extractFlag(description.rest, "permissions"); + const name = positionals(permissionsFlag.rest)[0]; + if (!name) { + fail( + "Usage: seamless roles create [--description ] [--permissions a,b] [--json]", + ); + } + + const permissions = parseList(permissionsFlag.value); + for (const permission of permissions ?? []) checkName(permission, "permission"); + const body = validate(CreateOrganizationRoleRequestSchema, { + name: checkName(name, "role"), + ...(description.value !== undefined ? { description: description.value } : {}), + ...(permissions?.length ? { permissions: [...new Set(permissions)] } : {}), + }); + + const role = await createOrganizationRole(client, body); + printJsonOr(permissionsFlag.rest, role, `Created organization role ${role.name}.`); +} + +async function rolesUpdate(client: AuthClient, args: string[]): Promise { + const description = extractFlag(args, "description"); + const clear = description.rest.includes("--no-description"); + const name = positionals(description.rest)[0]; + if (!name) { + fail( + "Usage: seamless roles update (--description | --no-description) [--json]", + ); + } + if (description.value === undefined && !clear) { + fail("Nothing to update. Pass --description , or --no-description to clear it."); + } + if (description.value !== undefined && clear) { + fail("Pass --description or --no-description, not both."); + } + + const body = validate(UpdateOrganizationRoleRequestSchema, { + description: clear ? null : description.value, + }); + const role = await updateOrganizationRole(client, name, body); + printJsonOr(description.rest, role, `Updated organization role ${role.name}.`); +} + +async function rolesDelete(client: AuthClient, args: string[]): Promise { + const name = positionals(args)[0]; + if (!name) fail("Usage: seamless roles delete [--force]"); + + const confirmed = await confirmDestructive({ + message: `Delete organization role ${name} and what it grants? This cannot be undone.`, + force: hasForceFlag(args), + }); + if (!confirmed) { + console.log(kleur.dim("Cancelled.")); + return; + } + + await deleteOrganizationRole(client, name); + console.log(kleur.green(`Deleted organization role ${name}.`)); +} diff --git a/src/core/admin.fixtures.ts b/src/core/admin.fixtures.ts index 6477c79..42b3e0a 100644 --- a/src/core/admin.fixtures.ts +++ b/src/core/admin.fixtures.ts @@ -3,6 +3,8 @@ import type { AdminCredential, AdminMembership, AdminOrganization, + AdminPermission, + AdminRole, AdminUser, } from "./admin.js"; @@ -112,3 +114,27 @@ export function apiResource(overrides: Partial = {}): OidcResource ...overrides, }; } + +export function apiPermission( + overrides: Partial = {}, +): AdminPermission { + return { + id: "p1", + name: "invoices:read", + description: null, + createdAt: CREATED, + updatedAt: CREATED, + ...overrides, + }; +} + +export function apiRole(overrides: Partial = {}): AdminRole { + return { + name: "billing", + kind: "organization", + description: null, + builtIn: false, + permissions: [], + ...overrides, + }; +} diff --git a/src/core/admin.ts b/src/core/admin.ts index a060ce1..ea777b2 100644 --- a/src/core/admin.ts +++ b/src/core/admin.ts @@ -15,10 +15,18 @@ import { OidcResourceResponseSchema, OrganizationMembershipSchema, OrganizationSchema, + PermissionEnvelopeResponseSchema, + PermissionListResponseSchema, + PermissionSchema, + RoleDefinitionSchema, + RoleEnvelopeResponseSchema, + RoleListResponseSchema, SessionSchema, UsersListResponseSchema, type AddOrganizationMemberRequest, type CreateOrganizationRequest, + type CreateOrganizationRoleRequest, + type CreatePermissionRequest, type DeviceReplacementRecoveryRequest, type DeviceReplacementRecoveryResponse, type OidcApplication, @@ -32,8 +40,11 @@ import { type OidcResource, type OidcResourceCreateRequest, type OidcResourceUpdateRequest, + type RoleKind, type UpdateOrganizationMemberRequest, type UpdateOrganizationRequest, + type UpdateOrganizationRoleRequest, + type UpdatePermissionRequest, } from "@seamless-auth/types"; import { z } from "zod"; import type { AuthClient } from "./authClient.js"; @@ -538,3 +549,181 @@ export async function deleteResource(client: AuthClient, id: string): Promise; +export type AdminRole = z.infer; +export type PermissionList = z.infer; + +export const PERMISSIONS_UNSUPPORTED_MESSAGE = + "This instance has no permission catalogue. Permissions and organization roles need Seamless Auth API v0.23 or later."; + +// An instance from before v0.23 has none of these routes, so it answers with the +// app's catch-all 404 (`{ error: "Not Found" }`). A missing permission or role on +// v0.23 is a 404 too, but it names what was missing, which is how the two differ. +function unsupported(res: ApiResponse, anyNotFound = false): void { + if (res.status !== 404) return; + if (anyNotFound || apiMessage(res.data) === "Not Found") { + throw new AdminApiError(PERMISSIONS_UNSUPPORTED_MESSAGE); + } +} + +// A 400 or 409 from these routes can carry `details` naming what is in the way: +// the roles still granting a permission, the permissions that do not exist, or +// how many memberships still hold a role. +function conflictDetail(data: unknown): string | undefined { + if (!data || typeof data !== "object") return undefined; + const details = (data as Record).details; + if (!details || typeof details !== "object") return undefined; + + const parts: string[] = []; + for (const [key, value] of Object.entries(details as Record)) { + if (Array.isArray(value)) { + const names = value.map((item) => { + if (item && typeof item === "object") { + const role = item as Record; + return `${String(role.kind)} role ${String(role.name)}`; + } + return String(item); + }); + parts.push(`${key}: ${names.join(", ")}`); + } else if (key === "holders" && typeof value === "number") { + parts.push(`held by ${value} membership${value === 1 ? "" : "s"}`); + } else { + parts.push(`${key}: ${String(value)}`); + } + } + return parts.length ? parts.join("; ") : undefined; +} + +function permissionsError(res: ApiResponse, summary: string): AdminApiError { + const error = apiError(res, summary); + const detail = conflictDetail(res.data); + return detail + ? new AdminApiError(`${error.message.replace(/\.$/, "")}, ${detail}.`) + : error; +} + +function permissionPath(name: string): string { + return `/admin/permissions/${encodeURIComponent(name)}`; +} + +function organizationRolePath(name: string): string { + return `/admin/organization-roles/${encodeURIComponent(name)}`; +} + +export async function listPermissions(client: AuthClient): Promise { + const res = await call(client, "GET", "/admin/permissions"); + unsupported(res, true); + if (!res.ok) throw apiError(res, "Could not list permissions"); + return parse(PermissionListSchema, res.data, "a permission list"); +} + +export async function createPermission( + client: AuthClient, + body: CreatePermissionRequest, +): Promise { + const res = await call(client, "POST", "/admin/permissions", body); + unsupported(res, true); + if (res.status === 409) { + throw apiError(res, `A permission named ${body.name} already exists`); + } + if (!res.ok) throw permissionsError(res, "Could not create permission"); + return parse(PermissionEnvelopeSchema, res.data, "a permission").permission; +} + +export async function updatePermission( + client: AuthClient, + name: string, + body: UpdatePermissionRequest, +): Promise { + const res = await call(client, "PATCH", permissionPath(name), body); + unsupported(res); + if (res.status === 404) throw apiError(res, `No permission named ${name}`); + if (!res.ok) throw permissionsError(res, "Could not update permission"); + return parse(PermissionEnvelopeSchema, res.data, "a permission").permission; +} + +export async function deletePermission(client: AuthClient, name: string): Promise { + const res = await call(client, "DELETE", permissionPath(name)); + unsupported(res); + if (res.status === 404) throw apiError(res, `No permission named ${name}`); + if (!res.ok) throw permissionsError(res, `Could not delete permission ${name}`); +} + +export async function listRoles(client: AuthClient): Promise { + const res = await call(client, "GET", "/admin/roles"); + unsupported(res, true); + if (!res.ok) throw apiError(res, "Could not list roles"); + return parse(RoleListSchema, res.data, "a role list").roles; +} + +export async function setRolePermissions( + client: AuthClient, + kind: RoleKind, + role: string, + permissions: string[], +): Promise { + const res = await call( + client, + "PUT", + `/admin/roles/${encodeURIComponent(kind)}/${encodeURIComponent(role)}/permissions`, + { permissions }, + ); + unsupported(res); + if (res.status === 404) throw apiError(res, `No ${kind} role named ${role}`); + if (!res.ok) throw permissionsError(res, `Could not set the permissions of ${role}`); + return parse(RoleEnvelopeSchema, res.data, "a role").role; +} + +export async function createOrganizationRole( + client: AuthClient, + body: CreateOrganizationRoleRequest, +): Promise { + const res = await call(client, "POST", "/admin/organization-roles", body); + unsupported(res, true); + if (!res.ok) throw permissionsError(res, "Could not create organization role"); + return parse(RoleEnvelopeSchema, res.data, "a role").role; +} + +export async function updateOrganizationRole( + client: AuthClient, + name: string, + body: UpdateOrganizationRoleRequest, +): Promise { + const res = await call(client, "PATCH", organizationRolePath(name), body); + unsupported(res); + if (res.status === 404) throw apiError(res, `No organization role named ${name}`); + if (!res.ok) throw permissionsError(res, "Could not update organization role"); + return parse(RoleEnvelopeSchema, res.data, "a role").role; +} + +export async function deleteOrganizationRole( + client: AuthClient, + name: string, +): Promise { + const res = await call(client, "DELETE", organizationRolePath(name)); + unsupported(res); + if (res.status === 404) throw apiError(res, `No organization role named ${name}`); + if (!res.ok) throw permissionsError(res, `Could not delete organization role ${name}`); +} diff --git a/src/core/adminPermissions.test.ts b/src/core/adminPermissions.test.ts new file mode 100644 index 0000000..2d383fa --- /dev/null +++ b/src/core/adminPermissions.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from "vitest"; +import type { AuthClient } from "./authClient.js"; +import type { ApiResponse } from "./http.js"; +import { + AdminApiError, + createOrganizationRole, + createPermission, + deleteOrganizationRole, + deletePermission, + listPermissions, + listRoles, + PERMISSIONS_UNSUPPORTED_MESSAGE, + setRolePermissions, + updateOrganizationRole, + updatePermission, +} from "./admin.js"; +import { StepUpError } from "./stepUp.js"; +import { apiPermission, apiRole } from "./admin.fixtures.js"; + +function response(status: number, data: T | null): ApiResponse { + return { ok: status >= 200 && status < 300, status, data, headers: new Headers() }; +} + +interface Recorded { + method: string; + path: string; + body?: unknown; +} + +function fakeClient( + handler: (rec: Recorded) => ApiResponse, +): { client: AuthClient; calls: Recorded[] } { + const calls: Recorded[] = []; + const record = (method: string, path: string, init?: RequestInit) => { + const rec: Recorded = { + method, + path, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }; + calls.push(rec); + return handler(rec); + }; + return { + calls, + client: { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: async (path) => record("GET", path) as never, + post: async (path) => record("POST", path) as never, + request: async (path, init) => + record((init?.method ?? "GET").toUpperCase(), path, init) as never, + }, + }; +} + +// What an instance from before v0.23 answers for a route it does not have. +const routeMissing = () => response(404, { error: "Not Found" }); + +async function message(promise: Promise): Promise { + const err = await promise.then( + () => undefined, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(AdminApiError); + return (err as Error).message; +} + +describe("permissions", () => { + it("lists the catalogue", async () => { + const { client, calls } = fakeClient(() => + response(200, { permissions: [apiPermission({ extra: 1 } as never)], total: 1 }), + ); + const list = await listPermissions(client); + expect(calls[0]).toMatchObject({ method: "GET", path: "/admin/permissions" }); + expect(list.total).toBe(1); + expect(list.permissions[0]).toMatchObject({ name: "invoices:read", extra: 1 }); + }); + + it("creates a permission", async () => { + const { client, calls } = fakeClient(() => + response(201, { permission: apiPermission({ description: "Read" }) }), + ); + const permission = await createPermission(client, { + name: "invoices:read", + description: "Read", + }); + expect(calls[0]).toEqual({ + method: "POST", + path: "/admin/permissions", + body: { name: "invoices:read", description: "Read" }, + }); + expect(permission.description).toBe("Read"); + }); + + it("names the duplicate on a 409", async () => { + const { client } = fakeClient(() => + response(409, { error: "Permission already exists" }), + ); + expect(await message(createPermission(client, { name: "invoices:read" }))).toBe( + "A permission named invoices:read already exists (409: Permission already exists).", + ); + }); + + it("reports any other failure to create", async () => { + const { client } = fakeClient(() => response(500, null)); + expect(await message(createPermission(client, { name: "a" }))).toBe( + "Could not create permission (500).", + ); + }); + + it("updates a description, encoding the name in the path", async () => { + const { client, calls } = fakeClient(() => + response(200, { permission: apiPermission({ name: "a:b" }) }), + ); + await updatePermission(client, "a:b", { description: null }); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/admin/permissions/a%3Ab", + body: { description: null }, + }); + }); + + it("names a missing permission on update and delete", async () => { + const { client } = fakeClient(() => response(404, { error: "Permission not found" })); + expect(await message(updatePermission(client, "x", { description: null }))).toBe( + "No permission named x (404: Permission not found).", + ); + expect(await message(deletePermission(client, "x"))).toBe( + "No permission named x (404: Permission not found).", + ); + }); + + it("reports any other failure to update", async () => { + const { client } = fakeClient(() => response(400, { error: "Bad request" })); + expect(await message(updatePermission(client, "x", { description: null }))).toBe( + "Could not update permission (400: Bad request).", + ); + }); + + it("deletes a permission", async () => { + const { client, calls } = fakeClient(() => response(200, { message: "Success" })); + await deletePermission(client, "invoices:read"); + expect(calls[0]).toMatchObject({ + method: "DELETE", + path: "/admin/permissions/invoices%3Aread", + }); + }); + + it("names the roles that still grant a permission it cannot delete", async () => { + const { client } = fakeClient(() => + response(409, { + error: "Permission is still granted by a role", + details: { + roles: [ + { kind: "instance", name: "admin" }, + { kind: "organization", name: "billing" }, + ], + }, + }), + ); + expect(await message(deletePermission(client, "invoices:read"))).toBe( + "Could not delete permission invoices:read (409: Permission is still granted by a role), roles: instance role admin, organization role billing.", + ); + }); + + it("parses a list it cannot read into a named error", async () => { + const { client } = fakeClient(() => response(200, { permissions: [{}], total: 1 })); + expect(await message(listPermissions(client))).toMatch( + /^The instance returned a permission list this CLI cannot read \(permissions\.0\.id: /, + ); + }); + + it("reports a failed list", async () => { + const { client } = fakeClient(() => response(500, { message: "boom." })); + expect(await message(listPermissions(client))).toBe( + "Could not list permissions (500: boom).", + ); + }); +}); + +describe("roles", () => { + it("lists both kinds", async () => { + const roles = [ + apiRole({ name: "admin", kind: "instance", permissions: ["a"] }), + apiRole(), + ]; + const { client, calls } = fakeClient(() => response(200, { roles })); + expect(await listRoles(client)).toEqual(roles); + expect(calls[0]).toMatchObject({ method: "GET", path: "/admin/roles" }); + }); + + it("reports a failed list", async () => { + const { client } = fakeClient(() => response(500, null)); + expect(await message(listRoles(client))).toBe("Could not list roles (500)."); + }); + + it("replaces a role's permissions", async () => { + const role = apiRole({ permissions: ["invoices:read"] }); + const { client, calls } = fakeClient(() => response(200, { role })); + expect( + await setRolePermissions(client, "organization", "billing", ["invoices:read"]), + ).toEqual(role); + expect(calls[0]).toEqual({ + method: "PUT", + path: "/admin/roles/organization/billing/permissions", + body: { permissions: ["invoices:read"] }, + }); + }); + + it("names a missing role on set", async () => { + const { client } = fakeClient(() => response(404, { error: "Role not found" })); + expect(await message(setRolePermissions(client, "instance", "ghost", []))).toBe( + "No instance role named ghost (404: Role not found).", + ); + }); + + it("names the permissions that do not exist", async () => { + const { client } = fakeClient(() => + response(400, { + error: "Unknown permissions", + details: { permissions: ["nope", "never"] }, + }), + ); + expect( + await message(setRolePermissions(client, "instance", "admin", ["nope", "never"])), + ).toBe( + "Could not set the permissions of admin (400: Unknown permissions), permissions: nope, never.", + ); + }); + + it("creates an organization role", async () => { + const role = apiRole({ permissions: ["a"] }); + const { client, calls } = fakeClient(() => response(201, { role })); + expect( + await createOrganizationRole(client, { name: "billing", permissions: ["a"] }), + ).toEqual(role); + expect(calls[0]).toEqual({ + method: "POST", + path: "/admin/organization-roles", + body: { name: "billing", permissions: ["a"] }, + }); + }); + + it("reports a role that already exists", async () => { + const { client } = fakeClient(() => + response(409, { error: "Organization role already exists" }), + ); + expect(await message(createOrganizationRole(client, { name: "billing" }))).toBe( + "Could not create organization role (409: Organization role already exists).", + ); + }); + + it("updates an organization role", async () => { + const role = apiRole({ description: "Pays" }); + const { client, calls } = fakeClient(() => response(200, { role })); + expect( + await updateOrganizationRole(client, "billing", { description: "Pays" }), + ).toEqual(role); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/admin/organization-roles/billing", + body: { description: "Pays" }, + }); + }); + + it("names a missing organization role on update and delete", async () => { + const { client } = fakeClient(() => response(404, { error: "Role not found" })); + expect( + await message(updateOrganizationRole(client, "ghost", { description: null })), + ).toBe("No organization role named ghost (404: Role not found)."); + expect(await message(deleteOrganizationRole(client, "ghost"))).toBe( + "No organization role named ghost (404: Role not found).", + ); + }); + + it("reports any other failure to update", async () => { + const { client } = fakeClient(() => response(500, null)); + expect( + await message(updateOrganizationRole(client, "billing", { description: null })), + ).toBe("Could not update organization role (500)."); + }); + + it("deletes an organization role", async () => { + const { client, calls } = fakeClient(() => response(200, { message: "Success" })); + await deleteOrganizationRole(client, "billing"); + expect(calls[0]).toMatchObject({ + method: "DELETE", + path: "/admin/organization-roles/billing", + }); + }); + + it("says how many memberships still hold a role it cannot delete", async () => { + const one = fakeClient(() => + response(409, { error: "Organization role is still held", details: { holders: 1 } }), + ); + expect(await message(deleteOrganizationRole(one.client, "billing"))).toBe( + "Could not delete organization role billing (409: Organization role is still held), held by 1 membership.", + ); + + const many = fakeClient(() => + response(409, { error: "Organization role is still held", details: { holders: 3 } }), + ); + expect(await message(deleteOrganizationRole(many.client, "billing"))).toMatch( + /held by 3 memberships\.$/, + ); + }); + + it("prints detail it does not recognise as it came", async () => { + const { client } = fakeClient(() => + response(409, { error: "Conflict", details: { reason: "other", empty: {} } }), + ); + expect(await message(deleteOrganizationRole(client, "billing"))).toBe( + "Could not delete organization role billing (409: Conflict), reason: other; empty: [object Object].", + ); + }); + + it("ignores details that are absent or not an object", async () => { + for (const data of [{ error: "Built in", details: "x" }, "plain", { details: {} }]) { + const { client } = fakeClient(() => response(409, data)); + expect(await message(deleteOrganizationRole(client, "owner"))).toMatch( + /^Could not delete organization role owner \(409(: Built in)?\)\.$/, + ); + } + }); +}); + +describe("an instance before v0.23", () => { + const calls: Array<[string, (client: AuthClient) => Promise]> = [ + ["permissions list", (c) => listPermissions(c)], + ["permissions create", (c) => createPermission(c, { name: "a" })], + ["permissions update", (c) => updatePermission(c, "a", { description: null })], + ["permissions delete", (c) => deletePermission(c, "a")], + ["roles list", (c) => listRoles(c)], + ["roles set", (c) => setRolePermissions(c, "instance", "admin", [])], + ["roles create", (c) => createOrganizationRole(c, { name: "billing" })], + ["roles update", (c) => updateOrganizationRole(c, "billing", { description: null })], + ["roles delete", (c) => deleteOrganizationRole(c, "billing")], + ]; + + it.each(calls)("%s says the instance needs v0.23", async (_name, run) => { + const { client } = fakeClient(routeMissing); + expect(await message(run(client))).toBe(PERMISSIONS_UNSUPPORTED_MESSAGE); + }); + + it("treats any 404 on a collection route as a missing route", async () => { + const { client } = fakeClient(() => response(404, null)); + expect(await message(listRoles(client))).toBe(PERMISSIONS_UNSUPPORTED_MESSAGE); + }); +}); + +describe("authorization", () => { + it("maps a step-up 403 to a StepUpError", async () => { + const { client } = fakeClient(() => + response(403, { error: "step_up_required", fresh: false, maxAgeSeconds: 300 }), + ); + await expect(deletePermission(client, "a")).rejects.toBeInstanceOf(StepUpError); + }); +}); diff --git a/src/index.test.ts b/src/index.test.ts index ba5faf1..92c6af9 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -19,6 +19,8 @@ vi.mock("./commands/sessions.js", () => ({ runSessions: vi.fn() })); vi.mock("./commands/config.js", () => ({ runConfig: vi.fn() })); vi.mock("./commands/users.js", () => ({ runUsers: vi.fn() })); vi.mock("./commands/org.js", () => ({ runOrg: vi.fn() })); +vi.mock("./commands/permissions.js", () => ({ runPermissions: vi.fn() })); +vi.mock("./commands/roles.js", () => ({ runRoles: vi.fn() })); vi.mock("./commands/apps.js", () => ({ runApps: vi.fn() })); vi.mock("./commands/templates.js", () => ({ runTemplates: vi.fn() })); @@ -248,6 +250,8 @@ describe("index dispatcher", () => { ["config", "./commands/config.js", "runConfig"], ["users", "./commands/users.js", "runUsers"], ["org", "./commands/org.js", "runOrg"], + ["permissions", "./commands/permissions.js", "runPermissions"], + ["roles", "./commands/roles.js", "runRoles"], ["apps", "./commands/apps.js", "runApps"], ["templates", "./commands/templates.js", "runTemplates"], ])("dispatches %s with the remaining args", async (cmd, modPath, fnName) => { diff --git a/src/index.ts b/src/index.ts index bf498a1..e0a59b0 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,10 @@ import { runSessions } from "./commands/sessions.js"; import { runConfig } from "./commands/config.js"; import { runUsers } from "./commands/users.js"; import { runOrg } from "./commands/org.js"; +import { runPermissions } from "./commands/permissions.js"; import { runClients } from "./commands/clients.js"; import { runResources } from "./commands/resources.js"; +import { runRoles } from "./commands/roles.js"; import { runApps } from "./commands/apps.js"; import { runMigrate } from "./commands/migrate.js"; import { runTemplates } from "./commands/templates.js"; @@ -136,6 +138,16 @@ async function main() { return; } + if (command === "permissions") { + await runPermissions(args.slice(1)); + return; + } + + if (command === "roles") { + await runRoles(args.slice(1)); + return; + } + if (command === "clients") { await runClients(args.slice(1)); return;