Skip to content
Draft
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,6 +37,7 @@ export const routes = buildRouteMap({
"data-connection": dataConnectionRoutes,
datastream: datastreamRoutes,
"datastream-token": datastreamTokenRoutes,
rbac: rbacRoutes,
cli: cliRoutes,
},
defaultCommand: "help",
Expand Down
152 changes: 152 additions & 0 deletions src/commands/rbac/dot-graph.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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];');
});
});
192 changes: 192 additions & 0 deletions src/commands/rbac/dot-graph.ts
Original file line number Diff line number Diff line change
@@ -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 <id>`).
* - {@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<string>();
recursivePlotGroups(out, groupsToGo, groupsDone, groupMap, members);
out.push("}");
return out.join("\n") + "\n";
}

function recursivePlotGroups(
out: string[],
groupsToGo: string[],
groupsDone: Set<string>,
groupMap: Map<string, RbacGroup>,
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";
}
Loading