From bf86bf3e4fc48a64cbeaf0358946bfae610c68f5 Mon Sep 17 00:00:00 2001 From: Aaron Brewbaker Date: Wed, 24 Jun 2026 11:59:27 -0400 Subject: [PATCH] feat(rbac): port RBAC read access and rbac-dot from Go CLI Adds read-only `observe rbac` commands ported from the deprecated Go CLI: - `rbac dot --user ` / `rbac dot --all`: GraphViz DOT graphs of the relationships between users, groups, and statements (mirrors cmd_rbac_dot.go). - `rbac group list` / `rbac group get ` (with optional --members), and `rbac statement list`: read-only listing/getting with --format json|csv. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + README.md | 4 + src/app.ts | 2 + src/commands/rbac/dot-graph.test.ts | 152 ++++++++++++++++++++ src/commands/rbac/dot-graph.ts | 192 +++++++++++++++++++++++++ src/commands/rbac/dot.test.ts | 204 +++++++++++++++++++++++++++ src/commands/rbac/dot.ts | 124 ++++++++++++++++ src/commands/rbac/group/get.ts | 95 +++++++++++++ src/commands/rbac/group/index.ts | 20 +++ src/commands/rbac/group/list.test.ts | 133 +++++++++++++++++ src/commands/rbac/group/list.ts | 76 ++++++++++ src/commands/rbac/index.ts | 25 ++++ src/commands/rbac/statement/index.ts | 17 +++ src/commands/rbac/statement/list.ts | 68 +++++++++ src/gql/rbac/group-members.ts | 31 ++++ src/gql/rbac/groups.ts | 28 ++++ src/gql/rbac/rbac.graphql | 79 +++++++++++ src/gql/rbac/statements.ts | 15 ++ src/gql/rbac/users.ts | 25 ++++ 19 files changed, 1292 insertions(+) create mode 100644 src/commands/rbac/dot-graph.test.ts create mode 100644 src/commands/rbac/dot-graph.ts create mode 100644 src/commands/rbac/dot.test.ts create mode 100644 src/commands/rbac/dot.ts create mode 100644 src/commands/rbac/group/get.ts create mode 100644 src/commands/rbac/group/index.ts create mode 100644 src/commands/rbac/group/list.test.ts create mode 100644 src/commands/rbac/group/list.ts create mode 100644 src/commands/rbac/index.ts create mode 100644 src/commands/rbac/statement/index.ts create mode 100644 src/commands/rbac/statement/list.ts create mode 100644 src/gql/rbac/group-members.ts create mode 100644 src/gql/rbac/groups.ts create mode 100644 src/gql/rbac/rbac.graphql create mode 100644 src/gql/rbac/statements.ts create mode 100644 src/gql/rbac/users.ts diff --git a/AGENTS.md b/AGENTS.md index 0198696..bbafc6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ src/ │ ├── datastream/ # Datastream commands (create, list, view, update) │ ├── ingest-token/ # Ingest token commands (create, list, view, update) │ ├── metric/ # Metric commands (list, view) +│ ├── rbac/ # RBAC read access + DOT graph (group, statement, dot) │ ├── skill/ # AI agent skill commands (list, view) │ ├── tag-key/ # Tag key commands (list) │ ├── tag-value/ # Tag value commands (list) @@ -57,6 +58,7 @@ src/ │ ├── datastream/ # Datastream queries/mutations │ ├── ingest-token/ # Ingest token queries/mutations │ ├── metric/ # Metric queries +│ ├── rbac/ # RBAC group/member/statement/user read queries │ ├── workspace/ # Workspace queries │ ├── gql-request.ts # GraphQL client/executor │ └── gql-codegen.config.ts # Codegen configuration diff --git a/README.md b/README.md index a7f6baa..2bfe324 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe datastream view` | View a datastream by ID | | `observe datastream update` | Update a datastream | | `observe datastream-token check-status` | Poll a datastream token until ingest data arrives | +| `observe rbac group list` | List RBAC groups | +| `observe rbac group get` | Get an RBAC group by ID (optionally with members) | +| `observe rbac statement list` | List RBAC statements | +| `observe rbac dot` | Output a GraphViz DOT graph of RBAC relationships | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration | | `observe cli upgrade` | Upgrade to the latest version | diff --git a/src/app.ts b/src/app.ts index a51b600..c465507 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { helpCommand } from "./commands/help.js"; import { ingestTokenRoutes } from "./commands/ingest-token/index.js"; import { metricRoutes } from "./commands/metric/index.js"; import { queryCommand } from "./commands/query.js"; +import { rbacRoutes } from "./commands/rbac/index.js"; import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; import { tagValueRoutes } from "./commands/tag-value/index.js"; @@ -36,6 +37,7 @@ export const routes = buildRouteMap({ "data-connection": dataConnectionRoutes, datastream: datastreamRoutes, "datastream-token": datastreamTokenRoutes, + rbac: rbacRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/rbac/dot-graph.test.ts b/src/commands/rbac/dot-graph.test.ts new file mode 100644 index 0000000..0311b39 --- /dev/null +++ b/src/commands/rbac/dot-graph.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; +import { + buildFullConnectivityDot, + buildUserGroupsDot, + statementLabel, +} from "./dot-graph"; +import type { RbacStatement } from "../../gql/rbac/statements"; + +function stmt(over: Partial): RbacStatement { + return { + id: "stmt-1", + description: "", + role: "Viewer", + subject: { userId: null, groupId: null, all: null }, + object: { + objectId: null, + folderId: null, + workspaceId: null, + type: null, + name: null, + owner: null, + all: null, + }, + ...over, + }; +} + +describe("statementLabel", () => { + test("objectId wins first", () => { + expect( + statementLabel(stmt({ object: { ...stmt({}).object, objectId: "42" } })), + ).toBe("Viewer obj 42"); + }); + test("folderId next", () => { + expect( + statementLabel(stmt({ object: { ...stmt({}).object, folderId: "7" } })), + ).toBe("Viewer fld 7"); + }); + test("workspaceId next", () => { + expect( + statementLabel( + stmt({ object: { ...stmt({}).object, workspaceId: "9" } }), + ), + ).toBe("Viewer wks 9"); + }); + test("type next", () => { + expect( + statementLabel(stmt({ object: { ...stmt({}).object, type: "Dataset" } })), + ).toBe("Viewer Dataset"); + }); + test("owner true", () => { + expect( + statementLabel(stmt({ object: { ...stmt({}).object, owner: true } })), + ).toBe("Viewer Owner"); + }); + test("all true", () => { + expect( + statementLabel(stmt({ object: { ...stmt({}).object, all: true } })), + ).toBe("Viewer All"); + }); + test("falls back to ?", () => { + expect(statementLabel(stmt({}))).toBe("Viewer ?"); + }); +}); + +describe("buildUserGroupsDot", () => { + test("plots transitive group membership for a user", () => { + const user = { + id: "100", + name: "Ada", + email: "a@x", + status: "Active", + role: "user", + } as never; + const groups = [ + { id: "g1", name: "Engineers", description: "" }, + { id: "g2", name: "Everyone", description: "" }, + ]; + const members = [ + { + id: "m1", + description: "", + groupId: "g1", + memberUserId: "100", + memberGroupId: null, + }, + { + id: "m2", + description: "", + groupId: "g2", + memberUserId: null, + memberGroupId: "g1", + }, + ]; + const dot = buildUserGroupsDot(user, groups, members); + expect(dot).toContain("digraph {"); + expect(dot).toContain(" node [shape=box];"); + expect(dot).toContain(" rankdir=LR;"); + expect(dot).toContain(" ranksep=1.5;"); + expect(dot).toContain(' "100" [label="Ada"];'); + expect(dot).toContain(' "100" -> "g1";'); + expect(dot).toContain(' "g1" [label="Engineers"];'); + expect(dot).toContain(' "g1" -> "g2";'); + expect(dot).toContain(' "g2" [label="Everyone"];'); + expect(dot.trimEnd().endsWith("}")).toBe(true); + }); +}); + +describe("buildFullConnectivityDot", () => { + test("emits clusters and weighted edges", () => { + const dot = buildFullConnectivityDot({ + users: [ + { + id: "100", + name: "Ada", + email: "a@x", + status: "Active", + role: "user", + } as never, + ], + groups: [{ id: "g1", name: "Engineers", description: "" }], + groupMembers: [ + { + id: "m1", + description: "", + groupId: "g1", + memberUserId: "100", + memberGroupId: null, + }, + ], + statements: [ + stmt({ + id: "s1", + subject: { userId: "100", groupId: null, all: null }, + object: { ...stmt({}).object, all: true }, + }), + ], + }); + expect(dot).toContain(" newrank=true;"); + expect(dot).toContain(" subgraph cluster_users {"); + expect(dot).toContain(' u_100 [label="Ada"];'); + expect(dot).toContain(" subgraph cluster_groups {"); + expect(dot).toContain(' "g1" [label="Engineers"];'); + expect(dot).toContain( + " All [shape=doublecircle width=2 height=2 fixedsize=true];", + ); + expect(dot).toContain(" subgraph cluster_statements {"); + expect(dot).toContain(' "s1" [label="Viewer All"];'); + expect(dot).toContain(' u_100 -> "g1" [weight=1];'); + expect(dot).toContain(' "s1" -> u_100 [weight=3];'); + }); +}); diff --git a/src/commands/rbac/dot-graph.ts b/src/commands/rbac/dot-graph.ts new file mode 100644 index 0000000..81eb5af --- /dev/null +++ b/src/commands/rbac/dot-graph.ts @@ -0,0 +1,192 @@ +import type { RbacGroup } from "../../gql/rbac/groups"; +import type { RbacGroupmember } from "../../gql/rbac/group-members"; +import type { RbacStatement } from "../../gql/rbac/statements"; +import type { RbacUser } from "../../gql/rbac/users"; + +/** + * GraphViz DOT generation for RBAC relationships. + * + * Ported from the Go CLI (`cmd_rbac_dot.go`). Two graphs are supported: + * + * - {@link buildUserGroupsDot}: the transitive group membership of a single + * user (`rbac dot --user `). + * - {@link buildFullConnectivityDot}: every user, group, and statement and the + * edges between them, laid out in three clusters (`rbac dot --all`). + * + * Go iterated Go maps (unordered) so the line order was nondeterministic; here + * we sort the collections so output is stable across runs. The emitted node + * shapes, cluster attributes, edge weights, and label formatting match the Go + * output exactly. + */ + +/** Quote a string as a DOT identifier/label (Go's `%q`). */ +function dotQuote(s: string): string { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** + * Human-readable statement label. Mirrors `dotStmtName` in the Go CLI: the + * first set object scoping field wins, in this exact priority order. + */ +export function statementLabel(s: RbacStatement): string { + const role = s.role; + const obj = s.object; + if (obj.objectId != null) { + return `${role} obj ${obj.objectId}`; + } + if (obj.folderId != null) { + return `${role} fld ${obj.folderId}`; + } + if (obj.workspaceId != null) { + return `${role} wks ${obj.workspaceId}`; + } + if (obj.type != null) { + return `${role} ${obj.type}`; + } + if (obj.owner === true) { + return `${role} Owner`; + } + if (obj.all === true) { + return `${role} All`; + } + return `${role} ?`; +} + +/** + * Build the transitive group-membership graph for a single user. + * Mirrors `plotUserGroupsDot` + `recursivePlotGroups`. + */ +export function buildUserGroupsDot( + user: RbacUser, + groups: RbacGroup[], + members: RbacGroupmember[], +): string { + const groupMap = new Map(groups.map((g) => [g.id, g])); + const out: string[] = []; + out.push("digraph {"); + out.push(" node [shape=box];"); + out.push(" rankdir=LR;"); + out.push(" ranksep=1.5;"); + out.push(` ${dotQuote(user.id)} [label=${dotQuote(user.name)}];`); + + // seed with the user's direct group memberships + const groupsToGo: string[] = []; + for (const m of members) { + if (m.memberUserId != null && m.memberUserId === user.id) { + out.push(` ${dotQuote(user.id)} -> ${dotQuote(m.groupId)};`); + groupsToGo.push(m.groupId); + } + } + + // plot transitive memberships + const groupsDone = new Set(); + recursivePlotGroups(out, groupsToGo, groupsDone, groupMap, members); + out.push("}"); + return out.join("\n") + "\n"; +} + +function recursivePlotGroups( + out: string[], + groupsToGo: string[], + groupsDone: Set, + groupMap: Map, + members: RbacGroupmember[], +): void { + for (const g of groupsToGo) { + if (groupsDone.has(g)) { + continue; + } + groupsDone.add(g); + const gobj = groupMap.get(g); + const label = gobj ? gobj.name : ""; + out.push(` ${dotQuote(g)} [label=${dotQuote(label)}];`); + const newgg: string[] = []; + for (const m of members) { + if (m.memberGroupId != null && m.memberGroupId === g) { + out.push(` ${dotQuote(g)} -> ${dotQuote(m.groupId)};`); + newgg.push(m.groupId); + } + } + recursivePlotGroups(out, newgg, groupsDone, groupMap, members); + } +} + +export interface RbacInstanceState { + users: RbacUser[]; + groups: RbacGroup[]; + groupMembers: RbacGroupmember[]; + statements: RbacStatement[]; +} + +/** + * Build the full connectivity graph across all users, groups and statements. + * Mirrors `plotFullConnectivityDot`. + */ +export function buildFullConnectivityDot(ri: RbacInstanceState): string { + const users = [...ri.users].sort((a, b) => a.id.localeCompare(b.id)); + const groups = [...ri.groups].sort((a, b) => a.id.localeCompare(b.id)); + const statements = [...ri.statements].sort((a, b) => + a.id.localeCompare(b.id), + ); + const groupMembers = [...ri.groupMembers].sort((a, b) => + a.id.localeCompare(b.id), + ); + const userIds = new Set(users.map((u) => u.id)); + + const out: string[] = []; + out.push("digraph {"); + out.push(" newrank=true;"); + out.push(" rankdir=LR;"); + out.push(" ranksep=10;"); + out.push(" subgraph cluster_users {"); + out.push(' label="Users";'); + out.push(" color=blue;"); + out.push(" node [shape=box fixedsize=true width=3 height=1];"); + for (const u of users) { + out.push(` u_${u.id} [label=${dotQuote(u.name)}];`); + } + out.push(" }"); + out.push(" subgraph cluster_groups {"); + out.push(' label="Groups";'); + out.push(" color=green;"); + out.push(" node [shape=house fixedsize=true width=3 height=2];"); + for (const g of groups) { + out.push(` ${dotQuote(g.id)} [label=${dotQuote(g.name)}];`); + } + out.push(" }"); + out.push(" All [shape=doublecircle width=2 height=2 fixedsize=true];"); + out.push(" subgraph cluster_statements {"); + out.push(' label="Statements";'); + out.push(" color=red;"); + out.push(" node [shape=oval fixedsize=true width=2 height=1];"); + for (const s of statements) { + out.push(` ${dotQuote(s.id)} [label=${dotQuote(statementLabel(s))}];`); + } + out.push(" }"); + + for (const gm of groupMembers) { + if (gm.memberUserId != null && userIds.has(gm.memberUserId)) { + out.push(` u_${gm.memberUserId} -> ${dotQuote(gm.groupId)} [weight=1];`); + } + } + for (const gm of groupMembers) { + if (gm.memberGroupId != null) { + out.push( + ` ${dotQuote(gm.memberGroupId)} -> ${dotQuote(gm.groupId)} [weight=2];`, + ); + } + } + for (const s of statements) { + if (s.subject.userId != null) { + out.push(` ${dotQuote(s.id)} -> u_${s.subject.userId} [weight=3];`); + } else if (s.subject.groupId != null) { + out.push( + ` ${dotQuote(s.id)} -> ${dotQuote(s.subject.groupId)} [weight=2];`, + ); + } else { + out.push(` ${dotQuote(s.id)} -> All [weight=1];`); + } + } + out.push("}"); + return out.join("\n") + "\n"; +} diff --git a/src/commands/rbac/dot.test.ts b/src/commands/rbac/dot.test.ts new file mode 100644 index 0000000..e378a34 --- /dev/null +++ b/src/commands/rbac/dot.test.ts @@ -0,0 +1,204 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const listRbacGroupsFn = mock(() => + Promise.resolve([{ id: "g1", name: "Engineers", description: "" }]), +); +const listRbacGroupmembersFn = mock(() => + Promise.resolve([ + { + id: "m1", + description: "", + groupId: "g1", + memberUserId: "100", + memberGroupId: null, + }, + ]), +); +const listRbacStatementsFn = mock(() => + Promise.resolve([ + { + id: "s1", + description: "", + role: "Viewer", + subject: { userId: "100", groupId: null, all: null }, + object: { + objectId: null, + folderId: null, + workspaceId: null, + type: null, + name: null, + owner: null, + all: true, + }, + }, + ]), +); +const listRbacUsersFn = mock(() => + Promise.resolve([ + { id: "100", name: "Ada", email: "a@x", status: "Active", role: "user" }, + ]), +); +const getRbacUserFn = mock((_c: unknown, vars: { id: string }) => + Promise.resolve( + vars.id === "100" + ? { id: "100", name: "Ada", email: "a@x", status: "Active", role: "user" } + : null, + ), +); + +let dot: (typeof import("./dot"))["dot"]; + +const deps = { + loadConfig: loadConfigFn, + listRbacGroups: listRbacGroupsFn, + listRbacGroupmembers: listRbacGroupmembersFn, + listRbacStatements: listRbacStatementsFn, + listRbacUsers: listRbacUsersFn, + getRbacUser: getRbacUserFn, +} as unknown as Parameters<(typeof import("./dot"))["dot"]>[1]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(resolve(repoRoot, "src/gql/rbac/groups.ts"), () => ({ + listRbacGroups: listRbacGroupsFn, + })); + void mock.module(resolve(repoRoot, "src/gql/rbac/group-members.ts"), () => ({ + listRbacGroupmembers: listRbacGroupmembersFn, + })); + void mock.module(resolve(repoRoot, "src/gql/rbac/statements.ts"), () => ({ + listRbacStatements: listRbacStatementsFn, + })); + void mock.module(resolve(repoRoot, "src/gql/rbac/users.ts"), () => ({ + listRbacUsers: listRbacUsersFn, + getRbacUser: getRbacUserFn, + })); + + const mod = await import("./dot.ts"); + dot = mod.dot; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + if (previousForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = previousForceColor; +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("rbac dot", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + listRbacGroupsFn.mockClear(); + listRbacGroupmembersFn.mockClear(); + listRbacStatementsFn.mockClear(); + listRbacUsersFn.mockClear(); + getRbacUserFn.mockClear(); + }); + + test("errors when neither --user nor --all given", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await dot.call(context, {}, deps); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("must specify exactly one"); + }); + + test("errors when both --user and --all given", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await dot.call(context, { user: "100", all: true }, deps); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("only specify one"); + }); + + test("--user emits a user-membership digraph", async () => { + const { context, stdout } = createMockContext(); + await dot.call(context, { user: "100" }, deps); + const out = stdout.join(""); + expect(out).toContain("digraph {"); + expect(out).toContain('"100" [label="Ada"];'); + expect(out).toContain('"100" -> "g1";'); + expect(getRbacUserFn).toHaveBeenCalledTimes(1); + }); + + test("--user with unknown id exits 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await dot.call(context, { user: "999" }, deps); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("user not found"); + }); + + test("--all emits full connectivity graph", async () => { + const { context, stdout } = createMockContext(); + await dot.call(context, { all: true }, deps); + const out = stdout.join(""); + expect(out).toContain("subgraph cluster_users {"); + expect(out).toContain("subgraph cluster_statements {"); + expect(out).toContain('"s1" -> u_100 [weight=3];'); + }); +}); diff --git a/src/commands/rbac/dot.ts b/src/commands/rbac/dot.ts new file mode 100644 index 0000000..927a791 --- /dev/null +++ b/src/commands/rbac/dot.ts @@ -0,0 +1,124 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { GqlApiError } from "../../gql/gql-request"; +import { listRbacGroups } from "../../gql/rbac/groups"; +import { listRbacGroupmembers } from "../../gql/rbac/group-members"; +import { listRbacStatements } from "../../gql/rbac/statements"; +import { getRbacUser, listRbacUsers } from "../../gql/rbac/users"; +import { loadConfig } from "../../lib/config"; +import { buildFullConnectivityDot, buildUserGroupsDot } from "./dot-graph"; + +interface RbacDotFlags { + user?: string; + all?: boolean; +} + +export interface RbacDotDeps { + loadConfig?: typeof loadConfig; + listRbacGroups?: typeof listRbacGroups; + listRbacGroupmembers?: typeof listRbacGroupmembers; + listRbacStatements?: typeof listRbacStatements; + listRbacUsers?: typeof listRbacUsers; + getRbacUser?: typeof getRbacUser; +} + +export async function dot( + this: LocalContext, + flags: RbacDotFlags, + deps: RbacDotDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + listRbacGroups: listRbacGroupsImpl = listRbacGroups, + listRbacGroupmembers: listRbacGroupmembersImpl = listRbacGroupmembers, + listRbacStatements: listRbacStatementsImpl = listRbacStatements, + listRbacUsers: listRbacUsersImpl = listRbacUsers, + getRbacUser: getRbacUserImpl = getRbacUser, + } = deps; + const { process, writer } = this; + + // Mirror the Go CLI: exactly one of --user / --all must be specified. + if (flags.user != null && flags.all) { + writer.error( + "Error: you can only specify one kind of plot (--user, --all)", + ); + process.exit(1); + return; + } + if (flags.user == null && !flags.all) { + writer.error("Error: you must specify exactly one plot (--user, --all)"); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + if (flags.user != null) { + const user = await getRbacUserImpl(config, { id: flags.user }); + if (user == null) { + writer.error(`Error: user not found: ${flags.user}`); + process.exit(1); + return; + } + const [groups, members] = await Promise.all([ + listRbacGroupsImpl(config), + listRbacGroupmembersImpl(config), + ]); + writer.write(buildUserGroupsDot(user, groups, members)); + return; + } + + const [users, groups, groupMembers, statements] = await Promise.all([ + listRbacUsersImpl(config), + listRbacGroupsImpl(config), + listRbacGroupmembersImpl(config), + listRbacStatementsImpl(config), + ]); + writer.write( + buildFullConnectivityDot({ users, groups, groupMembers, statements }), + ); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const dotCommand = buildCommand({ + loader: async () => dot, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + user: { + kind: "parsed", + parse: String, + brief: "Plot the membership graph for a single user (by user ID)", + optional: true, + }, + all: { + kind: "boolean", + brief: "Plot all users, groups, and statements", + optional: true, + }, + }, + }, + docs: { + brief: "Output a GraphViz DOT graph of RBAC relationships", + fullDescription: [ + "Output a GraphViz DOT graph of relationships between users, groups,", + "and statements. Pipe the output to `dot` to render an image, e.g.:", + "", + " observe rbac dot --all | dot -Tsvg -o rbac.svg", + "", + "Exactly one of --user or --all must be specified.", + ].join("\n"), + }, +}); diff --git a/src/commands/rbac/group/get.ts b/src/commands/rbac/group/get.ts new file mode 100644 index 0000000..3336d0a --- /dev/null +++ b/src/commands/rbac/group/get.ts @@ -0,0 +1,95 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../../context"; +import { GqlApiError } from "../../../gql/gql-request"; +import { getRbacGroup } from "../../../gql/rbac/groups"; +import { listRbacGroupmembers } from "../../../gql/rbac/group-members"; +import { loadConfig } from "../../../lib/config"; +import { renderAsCSV } from "../../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; + +interface GetRbacGroupFlags { + format?: OutputFormat; + members?: boolean; +} + +export interface GetRbacGroupDeps { + loadConfig?: typeof loadConfig; + getRbacGroup?: typeof getRbacGroup; + listRbacGroupmembers?: typeof listRbacGroupmembers; +} + +export async function get( + this: LocalContext, + flags: GetRbacGroupFlags, + id: string, + deps: GetRbacGroupDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + getRbacGroup: getRbacGroupImpl = getRbacGroup, + listRbacGroupmembers: listRbacGroupmembersImpl = listRbacGroupmembers, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const group = await getRbacGroupImpl(config, { id }); + + // Optionally fold in this group's direct members (read-only access to + // group membership), filtered client-side from the full membership list. + const result = flags.members + ? { + ...group, + members: (await listRbacGroupmembersImpl(config)).filter( + (m) => m.groupId === id, + ), + } + : group; + + if (flags.format === "csv") { + writer.write(renderAsCSV(result)); + return; + } + writer.write(JSON.stringify(result, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const getCommand = buildCommand({ + loader: async () => get, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "RBAC group ID (ORN)", + parse: String, + }, + ], + }, + flags: { + members: { + kind: "boolean", + brief: "Include the group's direct members", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + }, + }, + docs: { + brief: "Get an RBAC group by ID", + }, +}); diff --git a/src/commands/rbac/group/index.ts b/src/commands/rbac/group/index.ts new file mode 100644 index 0000000..0bb8d24 --- /dev/null +++ b/src/commands/rbac/group/index.ts @@ -0,0 +1,20 @@ +import { buildRouteMap } from "@stricli/core"; +import { getCommand } from "./get"; +import { listCommand } from "./list"; + +export const rbacGroupRoutes = buildRouteMap({ + routes: { + list: listCommand, + get: getCommand, + }, + docs: { + brief: "Read RBAC groups and their members", + fullDescription: [ + "Read-only access to RBAC groups.", + "", + "Commands:", + " list List RBAC groups", + " get Get an RBAC group by ID (optionally with members)", + ].join("\n"), + }, +}); diff --git a/src/commands/rbac/group/list.test.ts b/src/commands/rbac/group/list.test.ts new file mode 100644 index 0000000..a10adb5 --- /dev/null +++ b/src/commands/rbac/group/list.test.ts @@ -0,0 +1,133 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../../.."); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const listRbacGroupsFn = mock(() => + Promise.resolve([ + { id: "g1", name: "Engineers", description: "Eng group" }, + { id: "g2", name: "Everyone", description: "" }, + ]), +); + +let list: (typeof import("./list"))["list"]; + +const deps = { + loadConfig: loadConfigFn, + listRbacGroups: listRbacGroupsFn, +} as unknown as Parameters<(typeof import("./list"))["list"]>[1]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(resolve(repoRoot, "src/gql/rbac/groups.ts"), () => ({ + listRbacGroups: listRbacGroupsFn, + })); + + const mod = await import("./list.ts"); + list = mod.list; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + if (previousForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = previousForceColor; +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("rbac group list", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + listRbacGroupsFn.mockClear(); + }); + + test("outputs all groups as JSON by default", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, {}, deps); + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(2); + expect(output[0].name).toBe("Engineers"); + }); + + test("filters by name substring (case-insensitive)", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { match: "every" }, deps); + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(1); + expect(output[0].id).toBe("g2"); + }); + + test("renders CSV with --format csv", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { format: "csv" }, deps); + const out = stdout.join(""); + expect(out.split("\n")[0]).toBe("id,name,description"); + expect(out).toContain("g1,Engineers,Eng group"); + }); + + test("exits 1 on API error", async () => { + listRbacGroupsFn.mockImplementationOnce(() => { + throw new Error("boom"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await list.call(context, {}, deps); + } catch (e) { + expect((e as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/rbac/group/list.ts b/src/commands/rbac/group/list.ts new file mode 100644 index 0000000..894e3b8 --- /dev/null +++ b/src/commands/rbac/group/list.ts @@ -0,0 +1,76 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../../context"; +import { GqlApiError } from "../../../gql/gql-request"; +import { listRbacGroups } from "../../../gql/rbac/groups"; +import { loadConfig } from "../../../lib/config"; +import { filterByName } from "../../../lib/filter"; +import { renderAsCSV } from "../../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; + +interface ListRbacGroupsFlags { + match?: string; + format?: OutputFormat; +} + +export interface ListRbacGroupsDeps { + loadConfig?: typeof loadConfig; + listRbacGroups?: typeof listRbacGroups; +} + +export async function list( + this: LocalContext, + flags: ListRbacGroupsFlags, + deps: ListRbacGroupsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + listRbacGroups: listRbacGroupsImpl = listRbacGroups, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const groups = filterByName(await listRbacGroupsImpl(config), flags.match); + if (flags.format === "csv") { + writer.write(renderAsCSV(groups)); + return; + } + writer.write(JSON.stringify(groups, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + match: { + kind: "parsed", + parse: String, + brief: "Filter groups by name substring (case-insensitive)", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + }, + }, + docs: { + brief: "List RBAC groups", + }, +}); diff --git a/src/commands/rbac/index.ts b/src/commands/rbac/index.ts new file mode 100644 index 0000000..b928759 --- /dev/null +++ b/src/commands/rbac/index.ts @@ -0,0 +1,25 @@ +import { buildRouteMap } from "@stricli/core"; +import { dotCommand } from "./dot"; +import { rbacGroupRoutes } from "./group/index"; +import { rbacStatementRoutes } from "./statement/index"; + +export const rbacRoutes = buildRouteMap({ + routes: { + group: rbacGroupRoutes, + statement: rbacStatementRoutes, + dot: dotCommand, + }, + docs: { + brief: "Read RBAC groups, statements, and relationship graphs", + fullDescription: [ + "Read-only access to Role-Based Access Control (RBAC) data, plus", + "GraphViz DOT visualizations of the relationships between users,", + "groups, and statements.", + "", + "Commands:", + " group Read RBAC groups and their members", + " statement Read RBAC statements", + " dot Output a GraphViz DOT graph of RBAC relationships", + ].join("\n"), + }, +}); diff --git a/src/commands/rbac/statement/index.ts b/src/commands/rbac/statement/index.ts new file mode 100644 index 0000000..e03f54e --- /dev/null +++ b/src/commands/rbac/statement/index.ts @@ -0,0 +1,17 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list"; + +export const rbacStatementRoutes = buildRouteMap({ + routes: { + list: listCommand, + }, + docs: { + brief: "Read RBAC statements", + fullDescription: [ + "Read-only access to RBAC statements.", + "", + "Commands:", + " list List RBAC statements", + ].join("\n"), + }, +}); diff --git a/src/commands/rbac/statement/list.ts b/src/commands/rbac/statement/list.ts new file mode 100644 index 0000000..3d2bf47 --- /dev/null +++ b/src/commands/rbac/statement/list.ts @@ -0,0 +1,68 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../../context"; +import { GqlApiError } from "../../../gql/gql-request"; +import { listRbacStatements } from "../../../gql/rbac/statements"; +import { loadConfig } from "../../../lib/config"; +import { renderAsCSV } from "../../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; + +interface ListRbacStatementsFlags { + format?: OutputFormat; +} + +export interface ListRbacStatementsDeps { + loadConfig?: typeof loadConfig; + listRbacStatements?: typeof listRbacStatements; +} + +export async function list( + this: LocalContext, + flags: ListRbacStatementsFlags, + deps: ListRbacStatementsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + listRbacStatements: listRbacStatementsImpl = listRbacStatements, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const statements = await listRbacStatementsImpl(config); + if (flags.format === "csv") { + writer.write(renderAsCSV(statements)); + return; + } + writer.write(JSON.stringify(statements, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + }, + }, + docs: { + brief: "List RBAC statements", + }, +}); diff --git a/src/gql/rbac/group-members.ts b/src/gql/rbac/group-members.ts new file mode 100644 index 0000000..8fc529f --- /dev/null +++ b/src/gql/rbac/group-members.ts @@ -0,0 +1,31 @@ +import type { Config } from "../../lib/config"; +import { + ListRbacGroupmembersDocument, + type ListRbacGroupmembersQuery, + GetRbacGroupmemberDocument, + type GetRbacGroupmemberQuery, + type GetRbacGroupmemberQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type RbacGroupmember = + ListRbacGroupmembersQuery["rbacGroupmembers"][number]; + +export async function listRbacGroupmembers( + config: Config, +): Promise { + const response = await executeGraphQL(config, ListRbacGroupmembersDocument); + return response.data.rbacGroupmembers; +} + +export async function getRbacGroupmember( + config: Config, + variables: GetRbacGroupmemberQueryVariables, +): Promise { + const response = await executeGraphQL( + config, + GetRbacGroupmemberDocument, + variables, + ); + return response.data.rbacGroupmember; +} diff --git a/src/gql/rbac/groups.ts b/src/gql/rbac/groups.ts new file mode 100644 index 0000000..9ebf585 --- /dev/null +++ b/src/gql/rbac/groups.ts @@ -0,0 +1,28 @@ +import type { Config } from "../../lib/config"; +import { + ListRbacGroupsDocument, + type ListRbacGroupsQuery, + GetRbacGroupDocument, + type GetRbacGroupQuery, + type GetRbacGroupQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type RbacGroup = ListRbacGroupsQuery["rbacGroups"][number]; + +export async function listRbacGroups(config: Config): Promise { + const response = await executeGraphQL(config, ListRbacGroupsDocument); + return response.data.rbacGroups; +} + +export async function getRbacGroup( + config: Config, + variables: GetRbacGroupQueryVariables, +): Promise { + const response = await executeGraphQL( + config, + GetRbacGroupDocument, + variables, + ); + return response.data.rbacGroup; +} diff --git a/src/gql/rbac/rbac.graphql b/src/gql/rbac/rbac.graphql new file mode 100644 index 0000000..1061941 --- /dev/null +++ b/src/gql/rbac/rbac.graphql @@ -0,0 +1,79 @@ +query ListRbacGroups { + rbacGroups { + id + name + description + } +} + +query GetRbacGroup($id: ORN!) { + rbacGroup(id: $id) { + id + name + description + } +} + +query ListRbacGroupmembers { + rbacGroupmembers { + id + description + groupId + memberUserId + memberGroupId + } +} + +query GetRbacGroupmember($id: ORN!) { + rbacGroupmember(id: $id) { + id + description + groupId + memberUserId + memberGroupId + } +} + +query ListRbacStatements { + rbacStatements { + id + description + subject { + userId + groupId + all + } + object { + objectId + folderId + workspaceId + type + name + owner + all + } + role + } +} + +query ListRbacUsers { + currentCustomer { + users { + id + name: label + email + status + role + } + } +} + +query GetRbacUser($id: UserId!) { + user(id: $id) { + id + name: label + email + status + role + } +} diff --git a/src/gql/rbac/statements.ts b/src/gql/rbac/statements.ts new file mode 100644 index 0000000..ae04cee --- /dev/null +++ b/src/gql/rbac/statements.ts @@ -0,0 +1,15 @@ +import type { Config } from "../../lib/config"; +import { + ListRbacStatementsDocument, + type ListRbacStatementsQuery, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type RbacStatement = ListRbacStatementsQuery["rbacStatements"][number]; + +export async function listRbacStatements( + config: Config, +): Promise { + const response = await executeGraphQL(config, ListRbacStatementsDocument); + return response.data.rbacStatements; +} diff --git a/src/gql/rbac/users.ts b/src/gql/rbac/users.ts new file mode 100644 index 0000000..a1694a0 --- /dev/null +++ b/src/gql/rbac/users.ts @@ -0,0 +1,25 @@ +import type { Config } from "../../lib/config"; +import { + ListRbacUsersDocument, + type ListRbacUsersQuery, + GetRbacUserDocument, + type GetRbacUserQuery, + type GetRbacUserQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +type CurrentCustomer = NonNullable; +export type RbacUser = CurrentCustomer["users"][number]; + +export async function listRbacUsers(config: Config): Promise { + const response = await executeGraphQL(config, ListRbacUsersDocument); + return response.data.currentCustomer?.users ?? []; +} + +export async function getRbacUser( + config: Config, + variables: GetRbacUserQueryVariables, +): Promise { + const response = await executeGraphQL(config, GetRbacUserDocument, variables); + return response.data.user; +}