Skip to content

Commit 4ba0e0c

Browse files
authored
fix: fix isByosVault flag for secrets created with forceDB if readonly_vault is used (#3103)
1 parent 7b46215 commit 4ba0e0c

File tree

6 files changed

+143
-22
lines changed

6 files changed

+143
-22
lines changed

platform/backend/src/agents/chatops/chatops-manager.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,22 @@ export class ChatOpsManager {
199199
await this.seedConfigFromEnvVars();
200200

201201
// Load configs from DB (the single source of truth)
202+
// Errors are caught individually so a single broken config doesn't prevent other providers from initializing
202203
const [msTeamsConfig, slackConfig] = await Promise.all([
203-
ChatOpsConfigModel.getMsTeamsConfig(),
204-
ChatOpsConfigModel.getSlackConfig(),
204+
ChatOpsConfigModel.getMsTeamsConfig().catch((error) => {
205+
logger.error(
206+
{ error: error instanceof Error ? error.message : String(error) },
207+
"[ChatOps] Failed to load MS Teams config, skipping",
208+
);
209+
return null;
210+
}),
211+
ChatOpsConfigModel.getSlackConfig().catch((error) => {
212+
logger.error(
213+
{ error: error instanceof Error ? error.message : String(error) },
214+
"[ChatOps] Failed to load Slack config, skipping",
215+
);
216+
return null;
217+
}),
205218
]);
206219

207220
// Create providers with their config

platform/backend/src/models/chatops-config.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,13 @@ class ChatOpsConfigModel {
7272
}
7373

7474
private async getConfig<T>(secretName: string): Promise<T | null> {
75-
try {
76-
const secretRow = await SecretModel.findByName(secretName);
77-
if (!secretRow) return null;
75+
const secretRow = await SecretModel.findByName(secretName);
76+
if (!secretRow) return null;
7877

79-
const secret = await secretManager().getSecret(secretRow.id);
80-
if (!secret?.secret) return null;
78+
const secret = await secretManager().getSecret(secretRow.id);
79+
if (!secret?.secret) return null;
8180

82-
return secret.secret as unknown as T;
83-
} catch (error) {
84-
logger.error(
85-
{ error: error instanceof Error ? error.message : String(error) },
86-
`ChatOpsConfigModel: failed to read config "${secretName}"`,
87-
);
88-
return null;
89-
}
81+
return secret.secret as unknown as T;
9082
}
9183

9284
private async saveConfig(

platform/backend/src/secrets-manager/readonly-vault.ee.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SecretsManagerType } from "@shared";
1+
import { isVaultReference, SecretsManagerType } from "@shared";
22
import logger from "@/logging";
33
import SecretModel from "@/models/secret";
44
import {
@@ -115,8 +115,27 @@ export default class ReadonlyVaultSecretManager
115115
return dbRecord;
116116
}
117117

118+
const secretValues = dbRecord.secret as Record<string, unknown>;
119+
120+
// Self-heal corrupted records:
121+
// We had a bug where we were setting isByosVault to true during updateSecret for records created with forceDB=true introduced here:
122+
// https://github.com/archestra-ai/archestra/pull/1694/changes#diff-02457052fc1fed9d616aebeae6f7e0d984575808d3b218ccfd851300dcf7793aR426
123+
// In this case, if we detect that the secret values have is_byos_vault=true but are not vault references,
124+
// we need to fix the flag and return the DB record as-is.
125+
const hasAnyVaultReference = Object.values(secretValues).some(
126+
(v) => typeof v === "string" && isVaultReference(v),
127+
);
128+
if (!hasAnyVaultReference) {
129+
logger.warn(
130+
{ secretId },
131+
"BYOSVaultSecretManager.getSecret: isByosVault=true but no vault references found, fixing corrupted flag",
132+
);
133+
await SecretModel.update(secretId, { isByosVault: false });
134+
return { ...dbRecord, isByosVault: false };
135+
}
136+
118137
// All values in secret field are vault references (path#key format)
119-
const vaultReferences = dbRecord.secret as Record<string, string>;
138+
const vaultReferences = secretValues;
120139
if (Object.keys(vaultReferences).length === 0) {
121140
return dbRecord;
122141
}
@@ -195,8 +214,8 @@ export default class ReadonlyVaultSecretManager
195214
}
196215

197216
return await SecretModel.update(secretId, {
217+
// Do not modify isByosVault — that flag is set at creation time and shouldn't be changed during update.
198218
secret: _secretValue,
199-
isByosVault: true,
200219
});
201220
}
202221

@@ -219,7 +238,7 @@ export default class ReadonlyVaultSecretManager
219238
* Groups by path to minimize Vault API calls.
220239
*/
221240
private async resolveVaultReferences(
222-
references: Record<string, string>,
241+
references: Record<string, unknown>,
223242
): Promise<SecretValue> {
224243
const resolved: SecretValue = {};
225244

platform/backend/src/secrets-manager/secrets-manager.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ vi.mock("node-vault", () => {
2828
describe("SecretsManager", async () => {
2929
// biome-ignore lint/style/noRestrictedImports: dynamic import
3030
const VaultSecretManager = (await import("./vault.ee")).default;
31+
// biome-ignore lint/style/noRestrictedImports: dynamic import
32+
const ReadonlyVaultSecretManager = (await import("./readonly-vault.ee"))
33+
.default;
3134

3235
describe("getSecretsManagerTypeBasedOnEnvVars", () => {
3336
const originalEnv = process.env;
@@ -1133,4 +1136,94 @@ describe("SecretsManager", async () => {
11331136
});
11341137
});
11351138
});
1139+
1140+
describe("ReadonlyVaultSecretManager", () => {
1141+
const vaultConfig = {
1142+
address: "http://localhost:8200",
1143+
authMethod: "token" as const,
1144+
kvVersion: "2" as const,
1145+
token: "dev-root-token",
1146+
secretPath: "secret/data/archestra",
1147+
k8sTokenPath: "/var/run/secrets/kubernetes.io/serviceaccount/token",
1148+
k8sMountPoint: "kubernetes",
1149+
awsMountPoint: "aws",
1150+
awsRegion: "us-east-1",
1151+
awsStsEndpoint: "https://sts.amazonaws.com",
1152+
};
1153+
1154+
beforeEach(() => {
1155+
vi.clearAllMocks();
1156+
});
1157+
1158+
describe("getSecret - self-healing corrupted isByosVault", () => {
1159+
test("should fix isByosVault flag when secret values are not vault references", async () => {
1160+
const byosManager = new ReadonlyVaultSecretManager(vaultConfig);
1161+
1162+
// Create a forceDB secret (isByosVault=false, plain DB values)
1163+
const created = await byosManager.createSecret(
1164+
{ apiKey: "sk-test-key-123" },
1165+
"test-oauth-secret",
1166+
true,
1167+
);
1168+
expect(created.isByosVault).toBe(false);
1169+
1170+
// Simulate the bug: manually set isByosVault to true
1171+
await SecretModel.update(created.id, {
1172+
secret: { apiKey: "sk-test-key-123" },
1173+
isByosVault: true,
1174+
});
1175+
1176+
// getSecret should detect the mismatch and self-heal
1177+
const result = await byosManager.getSecret(created.id);
1178+
expect(result).not.toBeNull();
1179+
expect(result?.isByosVault).toBe(false);
1180+
expect(result?.secret).toEqual({ apiKey: "sk-test-key-123" });
1181+
1182+
// Verify the DB was fixed
1183+
const dbRecord = await SecretModel.findById(created.id);
1184+
expect(dbRecord?.isByosVault).toBe(false);
1185+
});
1186+
1187+
test("should not modify isByosVault when secret values are valid vault references", async () => {
1188+
const byosManager = new ReadonlyVaultSecretManager(vaultConfig);
1189+
1190+
// Create a proper BYOS secret with vault references
1191+
const created = await byosManager.createSecret(
1192+
{ apiKey: "secret/data/my-keys#api_key" },
1193+
"test-vault-ref",
1194+
);
1195+
expect(created.isByosVault).toBe(true);
1196+
1197+
// getSecret should try to resolve vault references (will fail since no real vault)
1198+
// but should NOT flip isByosVault to false
1199+
await expect(byosManager.getSecret(created.id)).rejects.toThrow();
1200+
1201+
// Verify the flag was NOT changed
1202+
const dbRecord = await SecretModel.findById(created.id);
1203+
expect(dbRecord?.isByosVault).toBe(true);
1204+
});
1205+
});
1206+
1207+
describe("updateSecret - preserves isByosVault", () => {
1208+
test("should not modify isByosVault flag on update", async () => {
1209+
const byosManager = new ReadonlyVaultSecretManager(vaultConfig);
1210+
1211+
// Create a forceDB secret (like OAuth tokens)
1212+
const created = await byosManager.createSecret(
1213+
{ access_token: "oauth-token-123", refresh_token: "refresh-456" },
1214+
"test-oauth",
1215+
true,
1216+
);
1217+
expect(created.isByosVault).toBe(false);
1218+
1219+
// Update with new plain values (like token refresh)
1220+
const updated = await byosManager.updateSecret(created.id, {
1221+
access_token: "new-oauth-token",
1222+
refresh_token: "new-refresh",
1223+
});
1224+
expect(updated).not.toBeNull();
1225+
expect(updated?.isByosVault).toBe(false);
1226+
});
1227+
});
1228+
});
11361229
});

platform/backend/src/types/secret.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const InsertSecretSchema = createInsertSchema(schema.secretsTable, {
2727
updatedAt: true,
2828
});
2929
export const UpdateSecretSchema = createUpdateSchema(schema.secretsTable, {
30-
secret: SecretValueSchema,
30+
secret: SecretValueSchema.optional(),
3131
}).omit({
3232
id: true,
3333
createdAt: true,

platform/frontend/src/lib/chatops.query.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ export function useChatOpsStatus() {
1212
return useQuery({
1313
queryKey: ["chatops", "status"],
1414
queryFn: async () => {
15-
const response = await archestraApiSdk.getChatOpsStatus();
16-
return response.data?.providers || [];
15+
const { data, error } = await archestraApiSdk.getChatOpsStatus();
16+
if (error) {
17+
handleApiError(error);
18+
return null;
19+
}
20+
return data?.providers || [];
1721
},
1822
});
1923
}

0 commit comments

Comments
 (0)