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
5 changes: 5 additions & 0 deletions .changeset/permission-commands.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ guidance may extend them but must not contradict them.
- Run from source: `npm run dev -- <command>` (`tsx`); or after building, `node dist/index.js <command>`
- 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/`.
Expand Down Expand Up @@ -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 <command> -h/--help`, and `seamless help <command>` all
render from the single registry in [src/commands/helpTopics.ts](src/commands/helpTopics.ts)
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
26 changes: 26 additions & 0 deletions src/commands/adminShared.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<T>(schema: z.ZodType<T>, 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
Expand Down
57 changes: 57 additions & 0 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,63 @@ org members remove <orgId> <userId> [--force]
},
],
},
{
name: "permissions",
usage: ["seamless permissions <list|create|update|delete>"],
sections: [
{
heading: "permissions <list|create|update|delete>",
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 <name> [--description <text>] [--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 <name> (--description <text> | --no-description) [--json]
permissions delete <name> [--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 <list|set|create|update|delete>"],
sections: [
{
heading: "roles <list|set|create|update|delete>",
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 <instance|organization> <role> (<permission>... | --none) [--force] [--json]
• Replaces everything the role grants. Every permission must exist
• Asks before taking a permission away, unless --force
roles create <name> [--description <text>] [--permissions a,b] [--json]
• Creates an organization role
roles update <name> (--description <text> | --no-description) [--json]
roles delete <name> [--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: [
Expand Down
235 changes: 235 additions & 0 deletions src/commands/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../core/tty.js")>();
return { ...actual, requireInteractive: vi.fn() };
});

vi.mock("../core/authClient.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../core/authClient.js")>();
return { ...actual, createAuthClient: vi.fn() };
});

vi.mock("../core/admin.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../core/admin.js")>();
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<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;

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<void> {
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 <list|create|update|delete>");

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 <name>");
});
});

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 <name>");

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 <name>");

await exits(["delete", "bad/name"]);
expect(errors()).toContain("not a valid permission name");
});
});
Loading
Loading