Skip to content

Commit 2b149ab

Browse files
feat(rbac): grant org-admin to super-admins team members
Members of the bootstrapped `super-admins` team now receive org-admin authority by writing the `team:super-admins#admin -> admin -> organization:<key>` tuple during team bootstrap. This lets super-admins manage and share any resource (e.g. create/transfer custom MCP tools) without per-resource grants. Idempotent: existing teams are topped up on the next bootstrap. Covered by new unit tests. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Sri Aradhyula <sraradhy@cisco.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2d33ae3 commit 2b149ab

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* @jest-environment node
3+
*
4+
* Verifies that `ensureSuperAdminsTeam` wires the Super Admins team to confer
5+
* organization-admin by writing the userset tuple
6+
* `team:super-admins#admin -> admin -> organization:<key>`.
7+
*
8+
* assisted-by Cursor claude-opus-4-7
9+
*/
10+
11+
const mockGetCollection = jest.fn();
12+
const mockWriteOpenFgaTuples = jest.fn();
13+
const mockResolveKeycloakUserSubject = jest.fn();
14+
const mockWriteTeamMembershipTuples = jest.fn();
15+
const mockMongoRoleToOpenFgaRelations = jest.fn();
16+
const mockUpsertTeamMembershipSource = jest.fn();
17+
const mockLoadActiveTeamMembers = jest.fn();
18+
19+
jest.mock("@/lib/mongodb", () => ({
20+
getCollection: (...args: unknown[]) => mockGetCollection(...args),
21+
isMongoDBConfigured: true,
22+
}));
23+
24+
jest.mock("@/lib/rbac/openfga", () => ({
25+
writeOpenFgaTuples: (...args: unknown[]) => mockWriteOpenFgaTuples(...args),
26+
}));
27+
28+
jest.mock("@/lib/rbac/team-membership-sync", () => ({
29+
resolveKeycloakUserSubject: (...args: unknown[]) => mockResolveKeycloakUserSubject(...args),
30+
writeTeamMembershipTuples: (...args: unknown[]) => mockWriteTeamMembershipTuples(...args),
31+
mongoRoleToOpenFgaRelations: (...args: unknown[]) => mockMongoRoleToOpenFgaRelations(...args),
32+
}));
33+
34+
jest.mock("@/lib/rbac/team-membership-source-store", () => ({
35+
upsertTeamMembershipSource: (...args: unknown[]) => mockUpsertTeamMembershipSource(...args),
36+
}));
37+
38+
jest.mock("@/lib/rbac/team-membership-store", () => ({
39+
loadActiveTeamMembers: (...args: unknown[]) => mockLoadActiveTeamMembers(...args),
40+
}));
41+
42+
const ORG_ADMIN_LINK_TUPLE = {
43+
user: "team:super-admins#admin",
44+
relation: "admin",
45+
object: "organization:caipe",
46+
};
47+
48+
describe("ensureSuperAdminsTeam org-admin linkage", () => {
49+
const originalEnv = { ...process.env };
50+
51+
beforeEach(() => {
52+
jest.resetModules();
53+
jest.clearAllMocks();
54+
process.env = { ...originalEnv };
55+
delete process.env.CAIPE_ORG_KEY;
56+
57+
mockResolveKeycloakUserSubject.mockResolvedValue("sub-a");
58+
mockMongoRoleToOpenFgaRelations.mockReturnValue(["admin"]);
59+
mockWriteTeamMembershipTuples.mockResolvedValue(undefined);
60+
mockUpsertTeamMembershipSource.mockResolvedValue(undefined);
61+
mockLoadActiveTeamMembers.mockResolvedValue([]);
62+
mockWriteOpenFgaTuples.mockImplementation(async (input: { writes: unknown[] }) => ({
63+
enabled: true,
64+
writes: input.writes.length,
65+
deletes: 0,
66+
}));
67+
});
68+
69+
afterAll(() => {
70+
process.env = originalEnv;
71+
});
72+
73+
it("writes the org-admin linkage tuple when creating the team", async () => {
74+
mockGetCollection.mockResolvedValue({
75+
findOne: jest.fn().mockResolvedValue(null),
76+
insertOne: jest.fn().mockResolvedValue({ insertedId: "team-id-1" }),
77+
updateOne: jest.fn().mockResolvedValue({}),
78+
});
79+
80+
const { ensureSuperAdminsTeam } = await import("../super-admins-team");
81+
const result = await ensureSuperAdminsTeam({
82+
members: [{ email: "a@cisco.com", userSubject: "sub-a" }],
83+
actor: "test",
84+
});
85+
86+
expect(result.status).toBe("created");
87+
expect(mockWriteOpenFgaTuples).toHaveBeenCalledWith({
88+
writes: [ORG_ADMIN_LINK_TUPLE],
89+
deletes: [],
90+
});
91+
});
92+
93+
it("writes the org-admin linkage tuple even when the team already exists", async () => {
94+
mockGetCollection.mockResolvedValue({
95+
findOne: jest.fn().mockResolvedValue({ _id: "team-id-1", slug: "super-admins", created_at: new Date() }),
96+
insertOne: jest.fn(),
97+
updateOne: jest.fn().mockResolvedValue({}),
98+
});
99+
// Member already present, so the run is a noop for membership.
100+
mockLoadActiveTeamMembers.mockResolvedValue([{ user_email: "a@cisco.com" }]);
101+
102+
const { ensureSuperAdminsTeam } = await import("../super-admins-team");
103+
const result = await ensureSuperAdminsTeam({
104+
members: [{ email: "a@cisco.com", userSubject: "sub-a" }],
105+
actor: "test",
106+
});
107+
108+
expect(result.status).toBe("noop");
109+
expect(mockWriteOpenFgaTuples).toHaveBeenCalledWith({
110+
writes: [ORG_ADMIN_LINK_TUPLE],
111+
deletes: [],
112+
});
113+
});
114+
115+
it("honors a custom org key via CAIPE_ORG_KEY", async () => {
116+
process.env.CAIPE_ORG_KEY = "grid";
117+
mockGetCollection.mockResolvedValue({
118+
findOne: jest.fn().mockResolvedValue(null),
119+
insertOne: jest.fn().mockResolvedValue({ insertedId: "team-id-1" }),
120+
updateOne: jest.fn().mockResolvedValue({}),
121+
});
122+
123+
const { ensureSuperAdminsTeam } = await import("../super-admins-team");
124+
await ensureSuperAdminsTeam({
125+
members: [{ email: "a@cisco.com", userSubject: "sub-a" }],
126+
actor: "test",
127+
});
128+
129+
expect(mockWriteOpenFgaTuples).toHaveBeenCalledWith({
130+
writes: [{ user: "team:super-admins#admin", relation: "admin", object: "organization:grid" }],
131+
deletes: [],
132+
});
133+
});
134+
135+
it("captures linkage failures in warnings without throwing", async () => {
136+
mockWriteOpenFgaTuples.mockRejectedValue(new Error("pdp unavailable"));
137+
mockGetCollection.mockResolvedValue({
138+
findOne: jest.fn().mockResolvedValue(null),
139+
insertOne: jest.fn().mockResolvedValue({ insertedId: "team-id-1" }),
140+
updateOne: jest.fn().mockResolvedValue({}),
141+
});
142+
143+
const { ensureSuperAdminsTeam } = await import("../super-admins-team");
144+
const result = await ensureSuperAdminsTeam({
145+
members: [{ email: "a@cisco.com", userSubject: "sub-a" }],
146+
actor: "test",
147+
});
148+
149+
expect(result.warnings).toEqual(
150+
expect.arrayContaining([expect.stringContaining("super-admins org-admin linkage")]),
151+
);
152+
});
153+
154+
it("skips entirely when no bootstrap admins are configured", async () => {
155+
const { ensureSuperAdminsTeam } = await import("../super-admins-team");
156+
const result = await ensureSuperAdminsTeam({ members: [], actor: "test" });
157+
158+
expect(result.status).toBe("skipped");
159+
expect(mockWriteOpenFgaTuples).not.toHaveBeenCalled();
160+
});
161+
});

ui/src/lib/rbac/super-admins-team.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
} from "@/lib/rbac/team-membership-sync";
2727
import { upsertTeamMembershipSource } from "@/lib/rbac/team-membership-source-store";
2828
import { loadActiveTeamMembers } from "@/lib/rbac/team-membership-store";
29+
import { writeOpenFgaTuples } from "@/lib/rbac/openfga";
30+
import { organizationObjectId } from "@/lib/rbac/organization";
2931
import type { TeamMembershipSource } from "@/types/identity-group-sync";
3032

3133
export const SUPER_ADMINS_TEAM_SLUG = "super-admins";
@@ -87,6 +89,40 @@ interface TeamDoc {
8789
members?: TeamMemberDoc[];
8890
}
8991

92+
/**
93+
* Idempotently grant every admin of the Super Admins team organization-admin
94+
* rights by writing the userset tuple
95+
* `team:super-admins#admin -> admin -> organization:<key>`.
96+
*
97+
* The OpenFGA model already declares `organization#admin: [..., team#admin]`,
98+
* so this single tuple makes membership in `super-admins` confer full
99+
* org-admin (which `can_manage organization` resolves through). Because the
100+
* team model implies `member` from `admin`, and the bootstrap seeds every
101+
* Super Admin as `team#admin`, this covers all members of the team.
102+
*
103+
* `writeOpenFgaTuples` filters out tuples that already exist, so this is a
104+
* cheap no-op on every subsequent startup. Failures are captured into the
105+
* caller's `warnings` array rather than thrown, matching the
106+
* never-throw contract of the surrounding bootstrap.
107+
*/
108+
async function ensureSuperAdminsOrgAdminLink(warnings: string[]): Promise<void> {
109+
try {
110+
await writeOpenFgaTuples({
111+
writes: [
112+
{
113+
user: `team:${SUPER_ADMINS_TEAM_SLUG}#admin`,
114+
relation: "admin",
115+
object: organizationObjectId(),
116+
},
117+
],
118+
deletes: [],
119+
});
120+
} catch (error) {
121+
const message = error instanceof Error ? error.message : String(error);
122+
warnings.push(`super-admins org-admin linkage: failed to write OpenFGA tuple: ${message}`);
123+
}
124+
}
125+
90126
function emptySkipped(reason: string): SuperAdminsBootstrapResult {
91127
return {
92128
status: "skipped",
@@ -123,6 +159,11 @@ export async function ensureSuperAdminsTeam(
123159
const warnings: string[] = [];
124160
const teams = await getCollection<TeamDoc>("teams");
125161

162+
// Make membership in the Super Admins team confer organization-admin. This
163+
// is independent of the Mongo team document, so we do it up front and it
164+
// applies whether we create the team below or top up an existing one.
165+
await ensureSuperAdminsOrgAdminLink(warnings);
166+
126167
// Resolve missing Keycloak subjects on the fly so the helper is callable
127168
// outside of `reconcileBootstrapAdmins` too (e.g. from a test or admin
128169
// tool). When `userSubject` is already populated we trust it.

0 commit comments

Comments
 (0)