@app-vitals/agent-admin 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +104 -1
- package/openapi.platform.json +104 -1
- package/package.json +2 -2
- package/prisma/client/commonInputTypes.ts +75 -0
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +9 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +9 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +27 -1
- package/prisma/migrations/20261028000000_add_repo_access_to_github_installations_snapshot/migration.sql +3 -0
- package/prisma/schema/schema.prisma +1 -0
- package/src/admin-boot.ts +7 -0
- package/src/agent-account-assignment.ts +42 -10
- package/src/agent-github-installations.integration.test.ts +32 -0
- package/src/agent-github-installations.ts +11 -1
- package/src/agent-provisioner.ts +69 -4
- package/src/agent-provisioner.unit.test.ts +136 -0
- package/src/agents-api.integration.test.ts +147 -0
- package/src/agents-api.smoke.test.ts +288 -1
- package/src/agents-api.ts +89 -4
- package/src/agents.ts +7 -0
- package/src/api.smoke.test.ts +15 -0
- package/src/api.ts +6 -2
- package/src/kubernetes-client.unit.test.ts +22 -0
- package/src/openapi-schemas.ts +51 -0
- package/src/openapi-schemas.unit.test.ts +31 -0
- package/src/repo-access-config.ts +22 -0
- package/src/repo-access-config.unit.test.ts +34 -0
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type KubernetesAgentProvisionerConfig,
|
|
22
22
|
NoopAgentProvisioner,
|
|
23
23
|
perAgentFederation,
|
|
24
|
+
podIdentityPatch,
|
|
24
25
|
policyDrifted,
|
|
25
26
|
} from "./agent-provisioner.ts";
|
|
26
27
|
import type { AgentTokenService } from "./agent-tokens.ts";
|
|
@@ -1138,6 +1139,7 @@ function manifestWith(
|
|
|
1138
1139
|
overrides: {
|
|
1139
1140
|
imageTag?: string;
|
|
1140
1141
|
anthropicFederation?: AgentAnthropicFederation;
|
|
1142
|
+
podServiceAccountName?: string;
|
|
1141
1143
|
} = {},
|
|
1142
1144
|
) {
|
|
1143
1145
|
return buildAgentDeploymentManifest({
|
|
@@ -1151,6 +1153,7 @@ function manifestWith(
|
|
|
1151
1153
|
tokenSecretKey: "token",
|
|
1152
1154
|
claudePolicy,
|
|
1153
1155
|
anthropicFederation: overrides.anthropicFederation,
|
|
1156
|
+
podServiceAccountName: overrides.podServiceAccountName,
|
|
1154
1157
|
});
|
|
1155
1158
|
}
|
|
1156
1159
|
|
|
@@ -1898,3 +1901,136 @@ describe("KubernetesAgentProvisioner — pod service account (COS-48.1)", () =>
|
|
|
1898
1901
|
).toBeUndefined();
|
|
1899
1902
|
});
|
|
1900
1903
|
});
|
|
1904
|
+
|
|
1905
|
+
// ─── Pod ServiceAccount drift (COS-49.1) ────────────────────────────────────
|
|
1906
|
+
|
|
1907
|
+
describe("podIdentityPatch()", () => {
|
|
1908
|
+
const withSa = (name?: string) =>
|
|
1909
|
+
manifestWith(undefined, { podServiceAccountName: name });
|
|
1910
|
+
|
|
1911
|
+
it("patches the desired ServiceAccount when the live one differs", () => {
|
|
1912
|
+
expect(podIdentityPatch(withSa(), withSa("agent-sa"))).toEqual({
|
|
1913
|
+
serviceAccountName: "agent-sa",
|
|
1914
|
+
});
|
|
1915
|
+
expect(podIdentityPatch(withSa("old-sa"), withSa("agent-sa"))).toEqual({
|
|
1916
|
+
serviceAccountName: "agent-sa",
|
|
1917
|
+
});
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
it("is undefined when already in sync", () => {
|
|
1921
|
+
expect(
|
|
1922
|
+
podIdentityPatch(withSa("agent-sa"), withSa("agent-sa")),
|
|
1923
|
+
).toBeUndefined();
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
it("clears serviceAccountName and serviceAccount when live equals the previous value", () => {
|
|
1927
|
+
expect(podIdentityPatch(withSa("agent-sa"), withSa(), "agent-sa")).toEqual({
|
|
1928
|
+
serviceAccountName: null,
|
|
1929
|
+
serviceAccount: null,
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
it("leaves a foreign ServiceAccount alone when clearing", () => {
|
|
1934
|
+
expect(
|
|
1935
|
+
podIdentityPatch(withSa("hand-set"), withSa(), "agent-sa"),
|
|
1936
|
+
).toBeUndefined();
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
it("never clears when the previous value is unknown", () => {
|
|
1940
|
+
expect(podIdentityPatch(withSa("agent-sa"), withSa())).toBeUndefined();
|
|
1941
|
+
expect(
|
|
1942
|
+
podIdentityPatch(withSa("agent-sa"), withSa(), null),
|
|
1943
|
+
).toBeUndefined();
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
it("defers to federationPatch when the desired Deployment has federation", () => {
|
|
1947
|
+
expect(
|
|
1948
|
+
podIdentityPatch(withSa("x"), fedManifest(FED_A), "x"),
|
|
1949
|
+
).toBeUndefined();
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
describe("KubernetesAgentProvisioner.reconcile() — pod ServiceAccount drift (COS-49.1)", () => {
|
|
1954
|
+
const live = (sa?: string) =>
|
|
1955
|
+
new RecordedKubernetesClient({
|
|
1956
|
+
deployments: {
|
|
1957
|
+
[`${NAMESPACE}/${POLICY_RESOURCE}`]: manifestWith(undefined, {
|
|
1958
|
+
podServiceAccountName: sa,
|
|
1959
|
+
}),
|
|
1960
|
+
},
|
|
1961
|
+
secrets: {},
|
|
1962
|
+
pvcs: {},
|
|
1963
|
+
});
|
|
1964
|
+
const provisioner = (client: KubernetesClient, enabled = true) =>
|
|
1965
|
+
new KubernetesAgentProvisioner(client, stubTokens() as AgentTokenService, {
|
|
1966
|
+
...BASE_CONFIG,
|
|
1967
|
+
podServiceAccount: { enabled },
|
|
1968
|
+
});
|
|
1969
|
+
const sa = async (rec: RecordedKubernetesClient) =>
|
|
1970
|
+
(await rec.getDeployment(NAMESPACE, POLICY_RESOURCE)).spec.template.spec
|
|
1971
|
+
.serviceAccountName;
|
|
1972
|
+
|
|
1973
|
+
for (const policyOnly of [false, true]) {
|
|
1974
|
+
it(`patches the desired ServiceAccount when live differs (policyOnly=${policyOnly})`, async () => {
|
|
1975
|
+
const rec = live("old-sa");
|
|
1976
|
+
const { client, patches } = patchSpy(rec);
|
|
1977
|
+
const result = await provisioner(client).reconcile(
|
|
1978
|
+
[{ id: POLICY_AGENT_ID, podServiceAccountName: "agent-sa" }],
|
|
1979
|
+
{ policyOnly },
|
|
1980
|
+
);
|
|
1981
|
+
expect(result.updated).toEqual([POLICY_AGENT_ID]);
|
|
1982
|
+
expect(patches).toHaveLength(1);
|
|
1983
|
+
expect(await sa(rec)).toBe("agent-sa");
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
it("clears a previously managed ServiceAccount", async () => {
|
|
1988
|
+
const rec = live("agent-sa");
|
|
1989
|
+
const result = await provisioner(rec).reconcile([
|
|
1990
|
+
{
|
|
1991
|
+
id: POLICY_AGENT_ID,
|
|
1992
|
+
podServiceAccountName: null,
|
|
1993
|
+
previousPodServiceAccountName: "agent-sa",
|
|
1994
|
+
},
|
|
1995
|
+
]);
|
|
1996
|
+
expect(result.updated).toEqual([POLICY_AGENT_ID]);
|
|
1997
|
+
expect(await sa(rec)).toBeUndefined();
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
it("leaves a hand-set ServiceAccount on an unmanaged Deployment alone", async () => {
|
|
2001
|
+
const rec = live("hand-set");
|
|
2002
|
+
const { client, patches } = patchSpy(rec);
|
|
2003
|
+
const result = await provisioner(client).reconcile(
|
|
2004
|
+
[
|
|
2005
|
+
{
|
|
2006
|
+
id: POLICY_AGENT_ID,
|
|
2007
|
+
podServiceAccountName: null,
|
|
2008
|
+
previousPodServiceAccountName: "agent-sa",
|
|
2009
|
+
},
|
|
2010
|
+
],
|
|
2011
|
+
{ policyOnly: true },
|
|
2012
|
+
);
|
|
2013
|
+
expect(result.updated).toEqual([]);
|
|
2014
|
+
expect(patches).toHaveLength(0);
|
|
2015
|
+
expect(await sa(rec)).toBe("hand-set");
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it("does nothing for a clear without a previous value (startup pass)", async () => {
|
|
2019
|
+
const rec = live("agent-sa");
|
|
2020
|
+
const { client, patches } = patchSpy(rec);
|
|
2021
|
+
await provisioner(client).reconcile(
|
|
2022
|
+
[{ id: POLICY_AGENT_ID, podServiceAccountName: null }],
|
|
2023
|
+
{ policyOnly: true },
|
|
2024
|
+
);
|
|
2025
|
+
expect(patches).toHaveLength(0);
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it("does not patch when the flag is off", async () => {
|
|
2029
|
+
const rec = live("hand-set");
|
|
2030
|
+
const { client, patches } = patchSpy(rec);
|
|
2031
|
+
await provisioner(client, false).reconcile([
|
|
2032
|
+
{ id: POLICY_AGENT_ID, podServiceAccountName: "agent-sa" },
|
|
2033
|
+
]);
|
|
2034
|
+
expect(patches).toHaveLength(0);
|
|
2035
|
+
});
|
|
2036
|
+
});
|
|
@@ -703,3 +703,150 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
703
703
|
expect((await fleet.json()).totals.input).toBe(510);
|
|
704
704
|
});
|
|
705
705
|
});
|
|
706
|
+
|
|
707
|
+
// ─── COS-49.1: PATCH /agents/:id podServiceAccountName ──────────────────────────
|
|
708
|
+
|
|
709
|
+
describeOrSkip("PATCH /agents/:id podServiceAccountName (integration)", () => {
|
|
710
|
+
let prisma: PrismaClient;
|
|
711
|
+
let agentId: string;
|
|
712
|
+
let cookie: string;
|
|
713
|
+
const reconciled: Array<Record<string, unknown>> = [];
|
|
714
|
+
|
|
715
|
+
const provisioner: AgentProvisioner = {
|
|
716
|
+
canProvision: true,
|
|
717
|
+
provision: async () => ({
|
|
718
|
+
resourceName: "r",
|
|
719
|
+
secretName: "s",
|
|
720
|
+
deploymentName: "d",
|
|
721
|
+
}),
|
|
722
|
+
deprovision: async () => {},
|
|
723
|
+
reconcile: async (targets: Array<{ id: string }>) => {
|
|
724
|
+
for (const t of targets) reconciled.push(t as Record<string, unknown>);
|
|
725
|
+
return {
|
|
726
|
+
recreated: [],
|
|
727
|
+
updated: [],
|
|
728
|
+
migrated: [],
|
|
729
|
+
noOwner: [],
|
|
730
|
+
orphans: [],
|
|
731
|
+
failed: [],
|
|
732
|
+
dryRun: false,
|
|
733
|
+
};
|
|
734
|
+
},
|
|
735
|
+
} as unknown as AgentProvisioner;
|
|
736
|
+
|
|
737
|
+
const patch = (
|
|
738
|
+
app: ReturnType<typeof createAdminApp>,
|
|
739
|
+
body: unknown,
|
|
740
|
+
headers: Record<string, string> = { Cookie: `admin_session=${cookie}` },
|
|
741
|
+
) =>
|
|
742
|
+
app.request(`/agents/${agentId}`, {
|
|
743
|
+
method: "PATCH",
|
|
744
|
+
body: JSON.stringify(body),
|
|
745
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
beforeEach(async () => {
|
|
749
|
+
prisma = makePrisma();
|
|
750
|
+
await prisma.agentWorkQueueSnapshot.deleteMany();
|
|
751
|
+
await prisma.agentPlugin.deleteMany();
|
|
752
|
+
await prisma.agentPhaseMethodology.deleteMany();
|
|
753
|
+
await prisma.agentToken.deleteMany();
|
|
754
|
+
await prisma.agentCronJob.deleteMany();
|
|
755
|
+
await prisma.agentTool.deleteMany();
|
|
756
|
+
await prisma.agentEnv.deleteMany();
|
|
757
|
+
await prisma.agentMember.deleteMany();
|
|
758
|
+
await prisma.agent.deleteMany();
|
|
759
|
+
agentId = await createAgent(prisma);
|
|
760
|
+
cookie = await makeSessionCookie();
|
|
761
|
+
reconciled.length = 0;
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
afterEach(async () => {
|
|
765
|
+
await prisma.$disconnect();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const appWith = (enabled: boolean) =>
|
|
769
|
+
createAdminApp({
|
|
770
|
+
...makeDeps(prisma, provisioner),
|
|
771
|
+
podServiceAccountEnabled: enabled,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("409 when the flag is off; the column is untouched", async () => {
|
|
775
|
+
const res = await patch(appWith(false), {
|
|
776
|
+
podServiceAccountName: "sa",
|
|
777
|
+
});
|
|
778
|
+
expect(res.status).toBe(409);
|
|
779
|
+
const row = await prisma.agent.findUniqueOrThrow({
|
|
780
|
+
where: { id: agentId },
|
|
781
|
+
});
|
|
782
|
+
expect(row.podServiceAccountName).toBeNull();
|
|
783
|
+
expect(reconciled).toEqual([]);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("403 for a non-admin session", async () => {
|
|
787
|
+
const memberCookie = await sign(
|
|
788
|
+
{
|
|
789
|
+
isAdmin: false,
|
|
790
|
+
userId: "user-m",
|
|
791
|
+
email: "member@example.com",
|
|
792
|
+
iat: Math.floor(Date.now() / 1000),
|
|
793
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
794
|
+
},
|
|
795
|
+
SESSION_SECRET,
|
|
796
|
+
"HS256",
|
|
797
|
+
);
|
|
798
|
+
await prisma.agentMember.create({
|
|
799
|
+
data: { agentId, email: "member@example.com" },
|
|
800
|
+
});
|
|
801
|
+
const res = await patch(
|
|
802
|
+
appWith(true),
|
|
803
|
+
{ podServiceAccountName: "sa" },
|
|
804
|
+
{ Cookie: `admin_session=${memberCookie}` },
|
|
805
|
+
);
|
|
806
|
+
expect(res.status).toBe(403);
|
|
807
|
+
expect(reconciled).toEqual([]);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("422 for an agent with a per-agent federated identity", async () => {
|
|
811
|
+
await prisma.agent.update({
|
|
812
|
+
where: { id: agentId },
|
|
813
|
+
data: {
|
|
814
|
+
ownerEmail: "jane.doe@example.com",
|
|
815
|
+
anthropicIdentityName: "id-49-1",
|
|
816
|
+
anthropicServiceAccountId: "svac_1",
|
|
817
|
+
anthropicFederationRuleId: "fdrl_1",
|
|
818
|
+
k8sServiceAccountName: "agent-sa",
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
const res = await patch(appWith(true), { podServiceAccountName: "sa" });
|
|
822
|
+
expect(res.status).toBe(422);
|
|
823
|
+
expect(reconciled).toEqual([]);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("200 persists the value, serializes it and reconciles once; null clears", async () => {
|
|
827
|
+
const app = appWith(true);
|
|
828
|
+
const res = await patch(app, { podServiceAccountName: "agent-pod-sa" });
|
|
829
|
+
expect(res.status).toBe(200);
|
|
830
|
+
expect((await res.json()).podServiceAccountName).toBe("agent-pod-sa");
|
|
831
|
+
expect(reconciled).toHaveLength(1);
|
|
832
|
+
expect(reconciled[0]).toMatchObject({
|
|
833
|
+
id: agentId,
|
|
834
|
+
podServiceAccountName: "agent-pod-sa",
|
|
835
|
+
previousPodServiceAccountName: null,
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const get = await app.request(`/agents/${agentId}`, {
|
|
839
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
840
|
+
});
|
|
841
|
+
expect((await get.json()).podServiceAccountName).toBe("agent-pod-sa");
|
|
842
|
+
|
|
843
|
+
const clear = await patch(app, { podServiceAccountName: null });
|
|
844
|
+
expect(clear.status).toBe(200);
|
|
845
|
+
expect((await clear.json()).podServiceAccountName).toBeNull();
|
|
846
|
+
expect(reconciled).toHaveLength(2);
|
|
847
|
+
expect(reconciled[1]).toMatchObject({
|
|
848
|
+
podServiceAccountName: null,
|
|
849
|
+
previousPodServiceAccountName: "agent-pod-sa",
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Services are injected as in-memory mocks.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { beforeAll, describe, expect, it } from "bun:test";
|
|
9
|
+
import { beforeAll, describe, expect, it, spyOn } from "bun:test";
|
|
10
10
|
import { sign } from "hono/jwt";
|
|
11
11
|
import type { Prisma } from "../prisma/client/client.ts";
|
|
12
12
|
import { getSelfServeSpecialty } from "./account-agent-create.ts";
|
|
@@ -199,6 +199,7 @@ function makeInMemoryGitHubInstallationsService(): AdminDeps["agentGitHubInstall
|
|
|
199
199
|
agentId: string;
|
|
200
200
|
reportedAt: Date;
|
|
201
201
|
installations: Prisma.JsonValue;
|
|
202
|
+
repoAccess: Prisma.JsonValue;
|
|
202
203
|
createdAt: Date;
|
|
203
204
|
}
|
|
204
205
|
>();
|
|
@@ -210,6 +211,9 @@ function makeInMemoryGitHubInstallationsService(): AdminDeps["agentGitHubInstall
|
|
|
210
211
|
agentId,
|
|
211
212
|
reportedAt: input.reportedAt,
|
|
212
213
|
installations: input.installations as Prisma.JsonValue,
|
|
214
|
+
repoAccess: (input.repoAccess === undefined
|
|
215
|
+
? (existing?.repoAccess ?? null)
|
|
216
|
+
: input.repoAccess) as Prisma.JsonValue,
|
|
213
217
|
createdAt: existing?.createdAt ?? new Date("2024-01-01"),
|
|
214
218
|
};
|
|
215
219
|
rows.set(agentId, row);
|
|
@@ -1262,6 +1266,66 @@ describe("admin API — GitHub installations snapshot", () => {
|
|
|
1262
1266
|
]);
|
|
1263
1267
|
});
|
|
1264
1268
|
|
|
1269
|
+
const REPOS = [
|
|
1270
|
+
{
|
|
1271
|
+
repo: "agent",
|
|
1272
|
+
owner: "app-vitals",
|
|
1273
|
+
read: true,
|
|
1274
|
+
write: false,
|
|
1275
|
+
checkedAt: "2026-01-01T00:00:00.000Z",
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
repo: "gone",
|
|
1279
|
+
owner: "app-vitals",
|
|
1280
|
+
read: false,
|
|
1281
|
+
write: false,
|
|
1282
|
+
checkedAt: "2026-01-01T00:00:00.000Z",
|
|
1283
|
+
error: "not_found",
|
|
1284
|
+
},
|
|
1285
|
+
];
|
|
1286
|
+
|
|
1287
|
+
it("PUT accepts repos and GET returns them as repoAccess (COS-44.1)", async () => {
|
|
1288
|
+
const app = createAdminApp(makeMockDeps());
|
|
1289
|
+
const res = await put(app, { ...BODY, repos: REPOS });
|
|
1290
|
+
expect(res.status).toBe(200);
|
|
1291
|
+
expect((await res.json()).snapshot.repoAccess).toEqual(REPOS);
|
|
1292
|
+
const getRes = await app.request(
|
|
1293
|
+
`/agents/${AGENT_ID}/github-installations`,
|
|
1294
|
+
{ headers: { Cookie: `admin_session=${cookie}` } },
|
|
1295
|
+
);
|
|
1296
|
+
expect((await getRes.json()).snapshot.repoAccess).toEqual(REPOS);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it("repoAccess is null until reported, and a push without repos keeps it", async () => {
|
|
1300
|
+
const app = createAdminApp(makeMockDeps());
|
|
1301
|
+
expect(
|
|
1302
|
+
(await (await put(app, BODY)).json()).snapshot.repoAccess,
|
|
1303
|
+
).toBeNull();
|
|
1304
|
+
await put(app, { ...BODY, repos: REPOS });
|
|
1305
|
+
const again = await put(app, BODY);
|
|
1306
|
+
expect((await again.json()).snapshot.repoAccess).toEqual(REPOS);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("rejects an unknown key or error class on a repos entry (400)", async () => {
|
|
1310
|
+
const app = createAdminApp(makeMockDeps());
|
|
1311
|
+
expect(
|
|
1312
|
+
(
|
|
1313
|
+
await put(app, {
|
|
1314
|
+
...BODY,
|
|
1315
|
+
repos: [{ ...REPOS[0], token: "ghs_secret" }],
|
|
1316
|
+
})
|
|
1317
|
+
).status,
|
|
1318
|
+
).toBe(400);
|
|
1319
|
+
expect(
|
|
1320
|
+
(
|
|
1321
|
+
await put(app, {
|
|
1322
|
+
...BODY,
|
|
1323
|
+
repos: [{ ...REPOS[1], error: "kaboom" }],
|
|
1324
|
+
})
|
|
1325
|
+
).status,
|
|
1326
|
+
).toBe(400);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1265
1329
|
it("rejects an extra top-level field (400)", async () => {
|
|
1266
1330
|
const app = createAdminApp(makeMockDeps());
|
|
1267
1331
|
const res = await put(app, { ...BODY, token: "ghs_secret" });
|
|
@@ -5451,6 +5515,229 @@ describe("admin API — PATCH /agents/:id accountId (SSP-5.3)", () => {
|
|
|
5451
5515
|
});
|
|
5452
5516
|
});
|
|
5453
5517
|
|
|
5518
|
+
// ─── COS-49.1: PATCH /agents/:id podServiceAccountName ───────────────────────
|
|
5519
|
+
|
|
5520
|
+
describe("admin API — PATCH /agents/:id podServiceAccountName (COS-49.1)", () => {
|
|
5521
|
+
const patch = (
|
|
5522
|
+
app: ReturnType<typeof createAdminApp>,
|
|
5523
|
+
body: unknown,
|
|
5524
|
+
headers: Record<string, string>,
|
|
5525
|
+
) =>
|
|
5526
|
+
app.request(`/agents/${AGENT_ID}`, {
|
|
5527
|
+
method: "PATCH",
|
|
5528
|
+
body: JSON.stringify(body),
|
|
5529
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
5530
|
+
});
|
|
5531
|
+
|
|
5532
|
+
const FEDERATED = {
|
|
5533
|
+
ownerEmail: "jane.doe@example.com",
|
|
5534
|
+
anthropicIdentityName: "id",
|
|
5535
|
+
anthropicServiceAccountId: "svac_1",
|
|
5536
|
+
anthropicFederationRuleId: "fdrl_1",
|
|
5537
|
+
k8sServiceAccountName: "agent-sa",
|
|
5538
|
+
};
|
|
5539
|
+
|
|
5540
|
+
function depsFor(opts: {
|
|
5541
|
+
provisioner: RecordingProvisioner;
|
|
5542
|
+
enabled?: boolean;
|
|
5543
|
+
existing?: Record<string, unknown>;
|
|
5544
|
+
updated?: Array<Record<string, unknown>>;
|
|
5545
|
+
}): AdminDeps {
|
|
5546
|
+
const base = makeMockDeps();
|
|
5547
|
+
return {
|
|
5548
|
+
...base,
|
|
5549
|
+
provisioner: opts.provisioner,
|
|
5550
|
+
podServiceAccountEnabled: opts.enabled ?? true,
|
|
5551
|
+
agentService: {
|
|
5552
|
+
...base.agentService,
|
|
5553
|
+
getDetail: async (id: string) => {
|
|
5554
|
+
const d = await base.agentService.getDetail(id);
|
|
5555
|
+
return d ? ({ ...d, ...opts.existing } as typeof d) : d;
|
|
5556
|
+
},
|
|
5557
|
+
updateSelfHosted: async (id, input) => {
|
|
5558
|
+
opts.updated?.push(input as Record<string, unknown>);
|
|
5559
|
+
return {
|
|
5560
|
+
...(await base.agentService.updateSelfHosted(id, input)),
|
|
5561
|
+
podServiceAccountName:
|
|
5562
|
+
(input as { podServiceAccountName?: string | null })
|
|
5563
|
+
.podServiceAccountName ?? null,
|
|
5564
|
+
};
|
|
5565
|
+
},
|
|
5566
|
+
},
|
|
5567
|
+
};
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
it("admin sets the value: 200, serialized, and reconcile invoked once with the previous value", async () => {
|
|
5571
|
+
const provisioner = new RecordingProvisioner();
|
|
5572
|
+
const app = createAdminApp(
|
|
5573
|
+
depsFor({
|
|
5574
|
+
provisioner,
|
|
5575
|
+
existing: { podServiceAccountName: "old-sa" },
|
|
5576
|
+
}),
|
|
5577
|
+
);
|
|
5578
|
+
const cookie = await makeSessionCookie();
|
|
5579
|
+
const res = await patch(
|
|
5580
|
+
app,
|
|
5581
|
+
{ podServiceAccountName: "new-sa" },
|
|
5582
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5583
|
+
);
|
|
5584
|
+
expect(res.status).toBe(200);
|
|
5585
|
+
expect(
|
|
5586
|
+
((await res.json()) as { podServiceAccountName: string })
|
|
5587
|
+
.podServiceAccountName,
|
|
5588
|
+
).toBe("new-sa");
|
|
5589
|
+
expect(provisioner.reconciled).toHaveLength(1);
|
|
5590
|
+
expect(provisioner.reconciled[0]).toMatchObject({
|
|
5591
|
+
id: AGENT_ID,
|
|
5592
|
+
podServiceAccountName: "new-sa",
|
|
5593
|
+
previousPodServiceAccountName: "old-sa",
|
|
5594
|
+
});
|
|
5595
|
+
});
|
|
5596
|
+
|
|
5597
|
+
it("null clears the value and passes the previous one", async () => {
|
|
5598
|
+
const provisioner = new RecordingProvisioner();
|
|
5599
|
+
const app = createAdminApp(
|
|
5600
|
+
depsFor({ provisioner, existing: { podServiceAccountName: "old-sa" } }),
|
|
5601
|
+
);
|
|
5602
|
+
const cookie = await makeSessionCookie();
|
|
5603
|
+
const res = await patch(
|
|
5604
|
+
app,
|
|
5605
|
+
{ podServiceAccountName: null },
|
|
5606
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5607
|
+
);
|
|
5608
|
+
expect(res.status).toBe(200);
|
|
5609
|
+
expect(provisioner.reconciled[0]).toMatchObject({
|
|
5610
|
+
podServiceAccountName: null,
|
|
5611
|
+
previousPodServiceAccountName: "old-sa",
|
|
5612
|
+
});
|
|
5613
|
+
});
|
|
5614
|
+
|
|
5615
|
+
it("409 naming the flag when it is off; nothing written or reconciled", async () => {
|
|
5616
|
+
const provisioner = new RecordingProvisioner();
|
|
5617
|
+
const updated: Array<Record<string, unknown>> = [];
|
|
5618
|
+
const app = createAdminApp(
|
|
5619
|
+
depsFor({ provisioner, enabled: false, updated }),
|
|
5620
|
+
);
|
|
5621
|
+
const cookie = await makeSessionCookie();
|
|
5622
|
+
const res = await patch(
|
|
5623
|
+
app,
|
|
5624
|
+
{ podServiceAccountName: "sa" },
|
|
5625
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5626
|
+
);
|
|
5627
|
+
expect(res.status).toBe(409);
|
|
5628
|
+
expect(JSON.stringify(await res.json())).toContain(
|
|
5629
|
+
"SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED",
|
|
5630
|
+
);
|
|
5631
|
+
expect(updated).toEqual([]);
|
|
5632
|
+
expect(provisioner.reconciled).toEqual([]);
|
|
5633
|
+
});
|
|
5634
|
+
|
|
5635
|
+
it("422 for an agent with a per-agent federated identity", async () => {
|
|
5636
|
+
const provisioner = new RecordingProvisioner();
|
|
5637
|
+
const updated: Array<Record<string, unknown>> = [];
|
|
5638
|
+
const app = createAdminApp(
|
|
5639
|
+
depsFor({ provisioner, existing: FEDERATED, updated }),
|
|
5640
|
+
);
|
|
5641
|
+
const cookie = await makeSessionCookie();
|
|
5642
|
+
const res = await patch(
|
|
5643
|
+
app,
|
|
5644
|
+
{ podServiceAccountName: "sa" },
|
|
5645
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5646
|
+
);
|
|
5647
|
+
expect(res.status).toBe(422);
|
|
5648
|
+
expect(updated).toEqual([]);
|
|
5649
|
+
expect(provisioner.reconciled).toEqual([]);
|
|
5650
|
+
});
|
|
5651
|
+
|
|
5652
|
+
it("403 for a non-admin session member", async () => {
|
|
5653
|
+
const provisioner = new RecordingProvisioner();
|
|
5654
|
+
const app = createAdminApp({
|
|
5655
|
+
...depsFor({ provisioner }),
|
|
5656
|
+
callerScopeResolver: async () => ({
|
|
5657
|
+
kind: "scoped",
|
|
5658
|
+
accountId: "acct-a",
|
|
5659
|
+
agentIds: [AGENT_ID],
|
|
5660
|
+
}),
|
|
5661
|
+
});
|
|
5662
|
+
const cookie = await sign(
|
|
5663
|
+
{
|
|
5664
|
+
isAdmin: false,
|
|
5665
|
+
userId: "user-acct",
|
|
5666
|
+
email: "owner@acct-a.example.com",
|
|
5667
|
+
iat: Math.floor(Date.now() / 1000),
|
|
5668
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
5669
|
+
},
|
|
5670
|
+
SESSION_SECRET,
|
|
5671
|
+
"HS256",
|
|
5672
|
+
);
|
|
5673
|
+
const res = await patch(
|
|
5674
|
+
app,
|
|
5675
|
+
{ podServiceAccountName: "sa" },
|
|
5676
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5677
|
+
);
|
|
5678
|
+
expect(res.status).toBe(403);
|
|
5679
|
+
expect(provisioner.reconciled).toEqual([]);
|
|
5680
|
+
});
|
|
5681
|
+
|
|
5682
|
+
it("rejects a value that is not an RFC 1123 label (400)", async () => {
|
|
5683
|
+
const app = createAdminApp(
|
|
5684
|
+
depsFor({ provisioner: new RecordingProvisioner() }),
|
|
5685
|
+
);
|
|
5686
|
+
const cookie = await makeSessionCookie();
|
|
5687
|
+
for (const bad of ["Bad_Name", "-lead", "a".repeat(64), ""]) {
|
|
5688
|
+
const res = await patch(
|
|
5689
|
+
app,
|
|
5690
|
+
{ podServiceAccountName: bad },
|
|
5691
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5692
|
+
);
|
|
5693
|
+
expect(res.status).toBe(400);
|
|
5694
|
+
}
|
|
5695
|
+
});
|
|
5696
|
+
|
|
5697
|
+
it("logs an audit line with agent, old, new and caller name but never the key", async () => {
|
|
5698
|
+
const provisioner = new RecordingProvisioner();
|
|
5699
|
+
const app = createAdminApp({
|
|
5700
|
+
...depsFor({
|
|
5701
|
+
provisioner,
|
|
5702
|
+
existing: { podServiceAccountName: "old-sa" },
|
|
5703
|
+
}),
|
|
5704
|
+
adminApiKeys: parseAdminApiKeys(`ops-key-name:${ADMIN_API_KEY}:*`),
|
|
5705
|
+
});
|
|
5706
|
+
const spy = spyOn(console, "info").mockImplementation(() => {});
|
|
5707
|
+
try {
|
|
5708
|
+
const res = await patch(
|
|
5709
|
+
app,
|
|
5710
|
+
{ podServiceAccountName: "new-sa" },
|
|
5711
|
+
{ Authorization: `Bearer ${ADMIN_API_KEY}` },
|
|
5712
|
+
);
|
|
5713
|
+
expect(res.status).toBe(200);
|
|
5714
|
+
const lines = spy.mock.calls.map((c) => String(c[0]));
|
|
5715
|
+
const line = lines.find((l) => l.includes("podServiceAccountName"));
|
|
5716
|
+
expect(line).toBeDefined();
|
|
5717
|
+
expect(line).toContain(AGENT_ID);
|
|
5718
|
+
expect(line).toContain("old-sa");
|
|
5719
|
+
expect(line).toContain("new-sa");
|
|
5720
|
+
expect(line).toContain("ops-key-name");
|
|
5721
|
+
expect(lines.join("\n")).not.toContain(ADMIN_API_KEY);
|
|
5722
|
+
} finally {
|
|
5723
|
+
spy.mockRestore();
|
|
5724
|
+
}
|
|
5725
|
+
});
|
|
5726
|
+
|
|
5727
|
+
it("does not touch the provisioner when the field is absent", async () => {
|
|
5728
|
+
const provisioner = new RecordingProvisioner();
|
|
5729
|
+
const app = createAdminApp(depsFor({ provisioner, enabled: false }));
|
|
5730
|
+
const cookie = await makeSessionCookie();
|
|
5731
|
+
const res = await patch(
|
|
5732
|
+
app,
|
|
5733
|
+
{ repos: ["org/repo"] },
|
|
5734
|
+
{ Cookie: `admin_session=${cookie}` },
|
|
5735
|
+
);
|
|
5736
|
+
expect(res.status).toBe(200);
|
|
5737
|
+
expect(provisioner.reconciled).toEqual([]);
|
|
5738
|
+
});
|
|
5739
|
+
});
|
|
5740
|
+
|
|
5454
5741
|
// ─── WIF-2.1: Anthropic credential guards under federation ───────────────────
|
|
5455
5742
|
|
|
5456
5743
|
describe("admin API — Anthropic credential guards (WIF-2.1)", () => {
|