misc: added import support and a few ui/ux updates

This commit is contained in:
Sheen Capadngan
2025-01-28 16:36:56 +08:00
parent effd88c4bd
commit 0a3e99b334
19 changed files with 179 additions and 107 deletions

View File

@@ -25,7 +25,7 @@ export const getGcpAppConnectionListItem = () => {
};
};
export const getAuthToken = async (appConnection: TGcpConnectionConfig) => {
export const getGcpConnectionAuthToken = async (appConnection: TGcpConnectionConfig) => {
const appCfg = getConfig();
if (!appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) {
throw new InternalServerError({
@@ -78,7 +78,7 @@ export const getAuthToken = async (appConnection: TGcpConnectionConfig) => {
};
export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection) => {
const accessToken = await getAuthToken(appConnection);
const accessToken = await getGcpConnectionAuthToken(appConnection);
let gcpApps: GCPApp[] = [];
@@ -146,19 +146,19 @@ export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection)
};
export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => {
// Check if provided service account email prefix matches organization ID.
// Check if provided service account email suffix matches organization ID.
// We do this to mitigate confused deputy attacks in multi-tenant instances
const expectedEmailPrefix = appConnection.orgId.split("-").slice(0, 2).join("-");
if (
appConnection.credentials.serviceAccountEmail &&
!appConnection.credentials.serviceAccountEmail.startsWith(expectedEmailPrefix)
) {
throw new BadRequestError({
message: `GCP service account email must have a prefix of "${expectedEmailPrefix}"`
});
if (appConnection.credentials.serviceAccountEmail) {
const expectedAccountIdSuffix = appConnection.orgId.split("-").slice(0, 2).join("-");
const serviceAccountId = appConnection.credentials.serviceAccountEmail.split("@")[0];
if (!serviceAccountId.endsWith(expectedAccountIdSuffix)) {
throw new BadRequestError({
message: `GCP service account ID (the part of the email before '@') must have a suffix of "${expectedAccountIdSuffix}"`
});
}
}
await getAuthToken(appConnection);
await getGcpConnectionAuthToken(appConnection);
return appConnection.credentials;
};

View File

@@ -6,5 +6,5 @@ export const GCP_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "GCP Secret Manager",
destination: SecretSync.GCPSecretManager,
connection: AppConnection.GCP,
canImportSecrets: false
canImportSecrets: true
};

View File

@@ -0,0 +1,3 @@
export enum GcpSyncScope {
Global = "global"
}

View File

@@ -1,9 +1,11 @@
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import { getAuthToken } from "@app/services/app-connection/gcp";
import { getGcpConnectionAuthToken } from "@app/services/app-connection/gcp";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps";
import { SecretSyncError } from "../secret-sync-errors";
import { TSecretMap } from "../secret-sync-types";
import {
GCPLatestSecretVersionAccess,
@@ -13,6 +15,8 @@ import {
} from "./gcp-sync-types";
const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => {
const { destinationConfig } = secretSync;
let gcpSecrets: GCPSecret[] = [];
const pageSize = 100;
@@ -48,21 +52,13 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden
pageToken = secretsRes.nextPageToken;
}
return gcpSecrets;
};
const res: { [key: string]: string } = {};
export const GcpSyncFns = {
syncSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, connection } = secretSync;
const accessToken = await getAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
const res: { [key: string]: string } = {};
for await (const gcpSecret of gcpSecrets) {
const arr = gcpSecret.name.split("/");
const key = arr[arr.length - 1];
for await (const gcpSecret of gcpSecrets) {
const arr = gcpSecret.name.split("/");
const key = arr[arr.length - 1];
try {
const { data: secretLatest } = await request.get<GCPLatestSecretVersionAccess>(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`,
{
@@ -74,100 +70,132 @@ export const GcpSyncFns = {
);
res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8");
} catch (error) {
// when a secret in GCP has no versions, we treat it as if it's a blank value
if (error instanceof AxiosError && error.response?.status === 404) {
res[key] = "";
} else {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
}
return res;
};
export const GcpSyncFns = {
syncSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, connection } = secretSync;
const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
for await (const key of Object.keys(secretMap)) {
if (!(key in res)) {
// case: create secret
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`,
{
replication: {
automatic: {}
}
},
{
params: {
secretId: key
try {
if (!(key in gcpSecrets)) {
// case: create secret
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`,
{
replication: {
automatic: {}
}
},
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
{
params: {
secretId: key
},
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
}
);
);
if (!secretMap[key].value) {
logger.warn(
`syncSecretsGcpsecretManager: create secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]`
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
{
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
}
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
{
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
for await (const key of Object.keys(res)) {
if (!(key in secretMap)) {
// case: delete secret
await request.delete(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
for await (const key of Object.keys(gcpSecrets)) {
try {
if (!(key in secretMap)) {
// case: delete secret
await request.delete(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
} else if (secretMap[key].value !== gcpSecrets[key]) {
if (!secretMap[key].value) {
logger.warn(
`syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]`
);
}
);
} else if (secretMap[key].value !== res[key]) {
if (!secretMap[key].value) {
logger.warn(
`syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]`
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
{
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
}
await request.post(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`,
{
payload: {
data: Buffer.from(secretMap[key].value).toString("base64")
}
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
},
getSecrets: async (secretSync: TGcpSyncWithCredentials): Promise<TSecretMap> => {
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`);
const { connection } = secretSync;
const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
return Object.fromEntries(Object.entries(gcpSecrets).map(([key, value]) => [key, { value: value ?? "" }]));
},
removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig, connection } = secretSync;
const accessToken = await getAuthToken(connection);
const accessToken = await getGcpConnectionAuthToken(connection);
const gcpSecrets = await getGcpSecrets(accessToken, secretSync);
for await (const entry of gcpSecrets) {
const arr = entry.name.split("/");
const key = arr[arr.length - 1];
for await (const [key] of Object.entries(gcpSecrets)) {
if (key in secretMap) {
await request.delete(
`${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`,

View File

@@ -9,10 +9,12 @@ import {
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
import { SecretSync } from "../secret-sync-enums";
import { GcpSyncScope } from "./gcp-sync-enums";
const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
const GcpSyncDestinationConfigSchema = z.object({
scope: z.literal(GcpSyncScope.Global),
projectId: z.string().min(1, "Project ID is required")
});
@@ -39,5 +41,5 @@ export const GcpSyncListItemSchema = z.object({
name: z.literal("GCP Secret Manager"),
connection: z.literal(AppConnection.GCP),
destination: z.literal(SecretSync.GCPSecretManager),
canImportSecrets: z.literal(false)
canImportSecrets: z.literal(true)
});

View File

@@ -1,3 +1,4 @@
export * from "./gcp-sync-constants";
export * from "./gcp-sync-enums";
export * from "./gcp-sync-schemas";
export * from "./gcp-sync-types";

View File

@@ -126,7 +126,7 @@ export const parseSyncErrorMessage = (err: unknown): string => {
if (err instanceof SecretSyncError) {
return JSON.stringify({
secretKey: err.secretKey,
error: err.message ?? parseSyncErrorMessage(err.error)
error: err.message || parseSyncErrorMessage(err.error)
});
}