Skip to content

Commit ea0aa0b

Browse files
committed
feat: implement OPA rollback functionality and state management
1 parent 8c52e2f commit ea0aa0b

File tree

7 files changed

+636
-4
lines changed

7 files changed

+636
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ packages/*/build/
2424

2525
# Okta bootstrap state
2626
.okta-bootstrap-state.json
27+
.opa-setup-state.json
2728
okta-config-report.md
2829
security-fixes.md
2930
CLAUDE.md

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"validate:opa": "tsx scripts/validate-opa-secrets.ts",
3434
"rollback:okta": "tsx scripts/rollback-okta-config.ts",
3535
"setup:opa": "tsx scripts/setup-opa-secrets.ts",
36+
"rollback:pam": "tsx scripts/rollback-opa-config.ts",
3637
"link:opa": "tsx scripts/link-opa-secrets.ts",
3738
"typecheck:scripts": "tsc --project scripts/tsconfig.json --noEmit"
3839
},

scripts/lib/agent-identity-api.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export interface CreateConnectionRequest {
6262

6363
export interface CreateVaultSecretConnectionRequest {
6464
connectionType: 'STS_VAULT_SECRET';
65-
resource: {
65+
resourceIndicator: string;
66+
secret: {
6667
orn: string;
6768
};
6869
}
@@ -367,7 +368,8 @@ export class AgentIdentityAPIClient {
367368
): Promise<AgentConnection> {
368369
const request: CreateVaultSecretConnectionRequest = {
369370
connectionType: 'STS_VAULT_SECRET',
370-
resource: {
371+
resourceIndicator: secretOrn,
372+
secret: {
371373
orn: secretOrn,
372374
},
373375
};
@@ -395,12 +397,32 @@ export class AgentIdentityAPIClient {
395397
this.getAxiosConfig()
396398
);
397399

398-
return response.data as AgentConnection[];
400+
// API returns { data: [...], _links: {...} }
401+
return (response.data?.data || []) as AgentConnection[];
399402
} catch (error: any) {
400403
this.handleAxiosError(error, 'List connections');
401404
}
402405
}
403406

407+
/**
408+
* Deactivate a connection between agent and authorization server
409+
*/
410+
async deactivateConnection(agentId: string, connectionId: string): Promise<void> {
411+
try {
412+
const response = await axios.post(
413+
`${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}/connections/${connectionId}/lifecycle/deactivate`,
414+
{},
415+
this.getAxiosConfig()
416+
);
417+
418+
if (response.status !== 200) {
419+
throw new Error(`Unexpected status: ${response.status}`);
420+
}
421+
} catch (error: any) {
422+
this.handleAxiosError(error, 'Deactivate connection');
423+
}
424+
}
425+
404426
/**
405427
* Delete a connection between agent and authorization server
406428
*/

scripts/lib/opa-api.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,78 @@ export class OPAClient {
628628
);
629629
return response.data;
630630
}
631+
632+
async listSecurityPolicies(): Promise<SecurityPolicy[]> {
633+
const response = await this.client.get(
634+
`/v1/teams/${this.config.teamName}/security_policy`
635+
);
636+
return response.data.list || [];
637+
}
638+
639+
async getSecurityPolicyByName(name: string): Promise<SecurityPolicy | null> {
640+
const policies = await this.listSecurityPolicies();
641+
return policies.find(p => p.name === name) || null;
642+
}
643+
644+
async deleteSecurityPolicy(policyId: string): Promise<void> {
645+
await this.client.delete(
646+
`/v1/teams/${this.config.teamName}/security_policy/${policyId}`
647+
);
648+
}
649+
650+
// ==========================================================================
651+
// Delete Operations
652+
// ==========================================================================
653+
654+
async deleteSecret(
655+
resourceGroupId: string,
656+
projectId: string,
657+
secretId: string
658+
): Promise<void> {
659+
await this.client.delete(
660+
`/v1/teams/${this.config.teamName}/resource_groups/${resourceGroupId}/projects/${projectId}/secrets/${secretId}`
661+
);
662+
}
663+
664+
async deleteSecretFolder(
665+
resourceGroupId: string,
666+
projectId: string,
667+
folderId: string
668+
): Promise<void> {
669+
await this.client.delete(
670+
`/v1/teams/${this.config.teamName}/resource_groups/${resourceGroupId}/projects/${projectId}/secret_folders/${folderId}`
671+
);
672+
}
673+
674+
async deleteProject(resourceGroupId: string, projectId: string): Promise<void> {
675+
await this.client.delete(
676+
`/v1/teams/${this.config.teamName}/resource_groups/${resourceGroupId}/projects/${projectId}`
677+
);
678+
}
679+
680+
async deleteResourceGroup(resourceGroupId: string): Promise<void> {
681+
await this.client.delete(
682+
`/v1/teams/${this.config.teamName}/resource_groups/${resourceGroupId}`
683+
);
684+
}
685+
686+
async deleteGroup(groupName: string): Promise<void> {
687+
await this.client.delete(
688+
`/v1/teams/${this.config.teamName}/groups/${groupName}`
689+
);
690+
}
691+
692+
async removeUserFromGroup(groupName: string, userName: string): Promise<void> {
693+
await this.client.delete(
694+
`/v1/teams/${this.config.teamName}/groups/${groupName}/users/${userName}`
695+
);
696+
}
697+
698+
async deleteServiceUser(userName: string): Promise<void> {
699+
await this.client.delete(
700+
`/v1/teams/${this.config.teamName}/service_users/${userName}`
701+
);
702+
}
631703
}
632704

633705
// ============================================================================

scripts/lib/opa-state-manager.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import * as fs from 'fs';
2+
3+
/**
4+
* OPA rollback state structure that tracks all resources created by setup-opa-secrets
5+
*/
6+
export interface OPARollbackState {
7+
baseUrl: string;
8+
teamName: string;
9+
resourceGroupId?: string;
10+
resourceGroupName?: string;
11+
projectId?: string;
12+
projectName?: string;
13+
folderId?: string;
14+
folderName?: string;
15+
serviceUserName?: string;
16+
groupName?: string;
17+
groupId?: string;
18+
securityPolicyName?: string;
19+
securityPolicyId?: string;
20+
secretIds: Array<{ name: string; id: string }>;
21+
serviceUserKeyIds: string[];
22+
}
23+
24+
const OPA_STATE_FILE_PATH = '.opa-setup-state.json';
25+
26+
/**
27+
* Initialize an empty OPA rollback state
28+
*/
29+
export function createEmptyOPAState(baseUrl: string, teamName: string): OPARollbackState {
30+
return {
31+
baseUrl,
32+
teamName,
33+
secretIds: [],
34+
serviceUserKeyIds: [],
35+
};
36+
}
37+
38+
/**
39+
* Load existing OPA rollback state or create a new one
40+
*/
41+
export function loadOPARollbackState(baseUrl: string, teamName: string): OPARollbackState {
42+
if (!fs.existsSync(OPA_STATE_FILE_PATH)) {
43+
return createEmptyOPAState(baseUrl, teamName);
44+
}
45+
46+
try {
47+
const content = fs.readFileSync(OPA_STATE_FILE_PATH, 'utf8');
48+
const state = JSON.parse(content) as OPARollbackState;
49+
50+
// Ensure all array fields exist (for backward compatibility)
51+
return {
52+
baseUrl: state.baseUrl || baseUrl,
53+
teamName: state.teamName || teamName,
54+
resourceGroupId: state.resourceGroupId,
55+
resourceGroupName: state.resourceGroupName,
56+
projectId: state.projectId,
57+
projectName: state.projectName,
58+
folderId: state.folderId,
59+
folderName: state.folderName,
60+
serviceUserName: state.serviceUserName,
61+
groupName: state.groupName,
62+
groupId: state.groupId,
63+
securityPolicyName: state.securityPolicyName,
64+
securityPolicyId: state.securityPolicyId,
65+
secretIds: state.secretIds || [],
66+
serviceUserKeyIds: state.serviceUserKeyIds || [],
67+
};
68+
} catch (error) {
69+
console.warn('Warning: Could not parse existing OPA state file, creating new state');
70+
return createEmptyOPAState(baseUrl, teamName);
71+
}
72+
}
73+
74+
/**
75+
* Load OPA rollback state without defaults (for rollback script)
76+
*/
77+
export function loadOPARollbackStateOnly(): OPARollbackState | null {
78+
if (!fs.existsSync(OPA_STATE_FILE_PATH)) {
79+
return null;
80+
}
81+
82+
try {
83+
const content = fs.readFileSync(OPA_STATE_FILE_PATH, 'utf8');
84+
return JSON.parse(content) as OPARollbackState;
85+
} catch (error) {
86+
return null;
87+
}
88+
}
89+
90+
/**
91+
* Update OPA rollback state
92+
*/
93+
export function updateOPARollbackState(
94+
currentState: OPARollbackState,
95+
updates: Partial<OPARollbackState>
96+
): OPARollbackState {
97+
// Merge arrays (append new items, avoid duplicates)
98+
const mergedState: OPARollbackState = {
99+
baseUrl: updates.baseUrl || currentState.baseUrl,
100+
teamName: updates.teamName || currentState.teamName,
101+
resourceGroupId: updates.resourceGroupId ?? currentState.resourceGroupId,
102+
resourceGroupName: updates.resourceGroupName ?? currentState.resourceGroupName,
103+
projectId: updates.projectId ?? currentState.projectId,
104+
projectName: updates.projectName ?? currentState.projectName,
105+
folderId: updates.folderId ?? currentState.folderId,
106+
folderName: updates.folderName ?? currentState.folderName,
107+
serviceUserName: updates.serviceUserName ?? currentState.serviceUserName,
108+
groupName: updates.groupName ?? currentState.groupName,
109+
groupId: updates.groupId ?? currentState.groupId,
110+
securityPolicyName: updates.securityPolicyName ?? currentState.securityPolicyName,
111+
securityPolicyId: updates.securityPolicyId ?? currentState.securityPolicyId,
112+
secretIds: mergeSecretArrays(currentState.secretIds, updates.secretIds),
113+
serviceUserKeyIds: mergeArrays(currentState.serviceUserKeyIds, updates.serviceUserKeyIds),
114+
};
115+
116+
// Write to temp file first, then rename for atomic operation
117+
const tempPath = `${OPA_STATE_FILE_PATH}.tmp`;
118+
try {
119+
fs.writeFileSync(tempPath, JSON.stringify(mergedState, null, 2), 'utf8');
120+
fs.renameSync(tempPath, OPA_STATE_FILE_PATH);
121+
} catch (error) {
122+
// Clean up temp file if it exists
123+
if (fs.existsSync(tempPath)) {
124+
fs.unlinkSync(tempPath);
125+
}
126+
throw error;
127+
}
128+
129+
return mergedState;
130+
}
131+
132+
/**
133+
* Delete OPA rollback state file
134+
*/
135+
export function deleteOPARollbackState(): void {
136+
if (fs.existsSync(OPA_STATE_FILE_PATH)) {
137+
fs.unlinkSync(OPA_STATE_FILE_PATH);
138+
}
139+
}
140+
141+
/**
142+
* Merge two string arrays, avoiding duplicates
143+
*/
144+
function mergeArrays(existing: string[] = [], newItems: string[] = []): string[] {
145+
const merged = [...existing];
146+
for (const item of newItems) {
147+
if (item && !merged.includes(item)) {
148+
merged.push(item);
149+
}
150+
}
151+
return merged;
152+
}
153+
154+
/**
155+
* Merge two secret arrays, avoiding duplicates based on id
156+
*/
157+
function mergeSecretArrays(
158+
existing: Array<{ name: string; id: string }> = [],
159+
newItems: Array<{ name: string; id: string }> = []
160+
): Array<{ name: string; id: string }> {
161+
const merged = [...existing];
162+
for (const item of newItems) {
163+
if (item && !merged.some(s => s.id === item.id)) {
164+
merged.push(item);
165+
}
166+
}
167+
return merged;
168+
}

0 commit comments

Comments
 (0)