Compare commits
32 Commits
readme-ssh
...
daniel/upd
Author | SHA1 | Date | |
---|---|---|---|
b949708f45 | |||
2a6b6b03b9 | |||
89c6ab591a | |||
235a33a01c | |||
dd6c217dc8 | |||
78b1b5583a | |||
8f2a504fd0 | |||
1d5b629d8f | |||
14f895cae2 | |||
b7be6bd1d9 | |||
58a97852f6 | |||
980aa9eaae | |||
a35d1aa72b | |||
c92c160709 | |||
71ca7a82db | |||
6f799b478d | |||
a89e6b6e58 | |||
99ca9e04f8 | |||
6cdc71b9b1 | |||
f88d6a183f | |||
fa82d4953e | |||
12d9fe9ffd | |||
3c1fc024c2 | |||
d627ecf05d | |||
6341b7e989 | |||
bc32d6cbbf | |||
0cf3115830 | |||
65f2e626ae | |||
8b3e3152a4 | |||
661b31f762 | |||
e78ad1147b | |||
473efa91f0 |
2
.github/workflows/deployment-pipeline.yml
vendored
@ -7,7 +7,7 @@ permissions:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "infisical-core-deployment"
|
group: "infisical-core-deployment"
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
infisical-tests:
|
infisical-tests:
|
||||||
|
2
backend/src/@types/fastify.d.ts
vendored
@ -80,6 +80,7 @@ import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-
|
|||||||
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||||
import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service";
|
import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service";
|
||||||
import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
||||||
|
import { TSecretSyncServiceFactory } from "@app/services/secret-sync/secret-sync-service";
|
||||||
import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
||||||
import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
|
import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
|
||||||
import { TSlackServiceFactory } from "@app/services/slack/slack-service";
|
import { TSlackServiceFactory } from "@app/services/slack/slack-service";
|
||||||
@ -210,6 +211,7 @@ declare module "fastify" {
|
|||||||
projectTemplate: TProjectTemplateServiceFactory;
|
projectTemplate: TProjectTemplateServiceFactory;
|
||||||
totp: TTotpServiceFactory;
|
totp: TTotpServiceFactory;
|
||||||
appConnection: TAppConnectionServiceFactory;
|
appConnection: TAppConnectionServiceFactory;
|
||||||
|
secretSync: TSecretSyncServiceFactory;
|
||||||
};
|
};
|
||||||
// this is exclusive use for middlewares in which we need to inject data
|
// this is exclusive use for middlewares in which we need to inject data
|
||||||
// everywhere else access using service layer
|
// everywhere else access using service layer
|
||||||
|
2
backend/src/@types/knex.d.ts
vendored
@ -372,6 +372,7 @@ import {
|
|||||||
TExternalGroupOrgRoleMappingsInsert,
|
TExternalGroupOrgRoleMappingsInsert,
|
||||||
TExternalGroupOrgRoleMappingsUpdate
|
TExternalGroupOrgRoleMappingsUpdate
|
||||||
} from "@app/db/schemas/external-group-org-role-mappings";
|
} from "@app/db/schemas/external-group-org-role-mappings";
|
||||||
|
import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs";
|
||||||
import {
|
import {
|
||||||
TSecretV2TagJunction,
|
TSecretV2TagJunction,
|
||||||
TSecretV2TagJunctionInsert,
|
TSecretV2TagJunctionInsert,
|
||||||
@ -900,5 +901,6 @@ declare module "knex/types/tables" {
|
|||||||
TAppConnectionsInsert,
|
TAppConnectionsInsert,
|
||||||
TAppConnectionsUpdate
|
TAppConnectionsUpdate
|
||||||
>;
|
>;
|
||||||
|
[TableName.SecretSync]: KnexOriginal.CompositeTableType<TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
50
backend/src/db/migrations/20250122055102_secret-sync.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasTable(TableName.SecretSync))) {
|
||||||
|
await knex.schema.createTable(TableName.SecretSync, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.string("name", 32).notNullable();
|
||||||
|
t.string("description");
|
||||||
|
t.string("destination").notNullable();
|
||||||
|
t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true);
|
||||||
|
t.integer("version").defaultTo(1).notNullable();
|
||||||
|
t.jsonb("destinationConfig").notNullable();
|
||||||
|
t.jsonb("syncOptions").notNullable();
|
||||||
|
// we're including projectId in addition to folder ID because we allow folderId to be null (if the folder
|
||||||
|
// is deleted), to preserve sync configuration
|
||||||
|
t.string("projectId").notNullable();
|
||||||
|
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||||
|
t.uuid("folderId");
|
||||||
|
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("SET NULL");
|
||||||
|
t.uuid("connectionId").notNullable();
|
||||||
|
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
// sync secrets to destination
|
||||||
|
t.string("syncStatus");
|
||||||
|
t.string("lastSyncJobId");
|
||||||
|
t.string("lastSyncMessage");
|
||||||
|
t.datetime("lastSyncedAt");
|
||||||
|
// import secrets from destination
|
||||||
|
t.string("importStatus");
|
||||||
|
t.string("lastImportJobId");
|
||||||
|
t.string("lastImportMessage");
|
||||||
|
t.datetime("lastImportedAt");
|
||||||
|
// remove secrets from destination
|
||||||
|
t.string("removeStatus");
|
||||||
|
t.string("lastRemoveJobId");
|
||||||
|
t.string("lastRemoveMessage");
|
||||||
|
t.datetime("lastRemovedAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
await createOnUpdateTrigger(knex, TableName.SecretSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.SecretSync);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.SecretSync);
|
||||||
|
}
|
@ -131,7 +131,8 @@ export enum TableName {
|
|||||||
WorkflowIntegrations = "workflow_integrations",
|
WorkflowIntegrations = "workflow_integrations",
|
||||||
SlackIntegrations = "slack_integrations",
|
SlackIntegrations = "slack_integrations",
|
||||||
ProjectSlackConfigs = "project_slack_configs",
|
ProjectSlackConfigs = "project_slack_configs",
|
||||||
AppConnection = "app_connections"
|
AppConnection = "app_connections",
|
||||||
|
SecretSync = "secret_syncs"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
|
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
|
||||||
|
40
backend/src/db/schemas/secret-syncs.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const SecretSyncsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
destination: z.string(),
|
||||||
|
isAutoSyncEnabled: z.boolean().default(true),
|
||||||
|
version: z.number().default(1),
|
||||||
|
destinationConfig: z.unknown(),
|
||||||
|
syncOptions: z.unknown(),
|
||||||
|
projectId: z.string(),
|
||||||
|
folderId: z.string().uuid().nullable().optional(),
|
||||||
|
connectionId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
syncStatus: z.string().nullable().optional(),
|
||||||
|
lastSyncJobId: z.string().nullable().optional(),
|
||||||
|
lastSyncMessage: z.string().nullable().optional(),
|
||||||
|
lastSyncedAt: z.date().nullable().optional(),
|
||||||
|
importStatus: z.string().nullable().optional(),
|
||||||
|
lastImportJobId: z.string().nullable().optional(),
|
||||||
|
lastImportMessage: z.string().nullable().optional(),
|
||||||
|
lastImportedAt: z.date().nullable().optional(),
|
||||||
|
removeStatus: z.string().nullable().optional(),
|
||||||
|
lastRemoveJobId: z.string().nullable().optional(),
|
||||||
|
lastRemoveMessage: z.string().nullable().optional(),
|
||||||
|
lastRemovedAt: z.date().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSecretSyncs = z.infer<typeof SecretSyncsSchema>;
|
||||||
|
export type TSecretSyncsInsert = Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>;
|
||||||
|
export type TSecretSyncsUpdate = Partial<Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>>;
|
@ -24,6 +24,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
),
|
),
|
||||||
name: z.string().trim(),
|
name: z.string().trim(),
|
||||||
description: z.string().trim().nullish(),
|
description: z.string().trim().nullish(),
|
||||||
|
// TODO(scott): once UI refactored permissions: OrgPermissionSchema.array()
|
||||||
permissions: z.any().array()
|
permissions: z.any().array()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
@ -96,6 +97,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
.optional(),
|
.optional(),
|
||||||
name: z.string().trim().optional(),
|
name: z.string().trim().optional(),
|
||||||
description: z.string().trim().nullish(),
|
description: z.string().trim().nullish(),
|
||||||
|
// TODO(scott): once UI refactored permissions: OrgPermissionSchema.array().optional()
|
||||||
permissions: z.any().array().optional()
|
permissions: z.any().array().optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
|
@ -81,7 +81,8 @@ export const auditLogServiceFactory = ({
|
|||||||
}
|
}
|
||||||
// add all cases in which project id or org id cannot be added
|
// add all cases in which project id or org id cannot be added
|
||||||
if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) {
|
if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) {
|
||||||
if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" });
|
if (!data.projectId && !data.orgId)
|
||||||
|
throw new BadRequestError({ message: "Must specify either project id or org id" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return auditLogQueue.pushToLog(data);
|
return auditLogQueue.pushToLog(data);
|
||||||
|
@ -13,6 +13,13 @@ import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
|
|||||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
|
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
|
||||||
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
||||||
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
|
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
|
||||||
|
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import {
|
||||||
|
TCreateSecretSyncDTO,
|
||||||
|
TDeleteSecretSyncDTO,
|
||||||
|
TSecretSyncRaw,
|
||||||
|
TUpdateSecretSyncDTO
|
||||||
|
} from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
export type TListProjectAuditLogDTO = {
|
export type TListProjectAuditLogDTO = {
|
||||||
filter: {
|
filter: {
|
||||||
@ -226,13 +233,22 @@ export enum EventType {
|
|||||||
DELETE_PROJECT_TEMPLATE = "delete-project-template",
|
DELETE_PROJECT_TEMPLATE = "delete-project-template",
|
||||||
APPLY_PROJECT_TEMPLATE = "apply-project-template",
|
APPLY_PROJECT_TEMPLATE = "apply-project-template",
|
||||||
GET_APP_CONNECTIONS = "get-app-connections",
|
GET_APP_CONNECTIONS = "get-app-connections",
|
||||||
|
GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details",
|
||||||
GET_APP_CONNECTION = "get-app-connection",
|
GET_APP_CONNECTION = "get-app-connection",
|
||||||
CREATE_APP_CONNECTION = "create-app-connection",
|
CREATE_APP_CONNECTION = "create-app-connection",
|
||||||
UPDATE_APP_CONNECTION = "update-app-connection",
|
UPDATE_APP_CONNECTION = "update-app-connection",
|
||||||
DELETE_APP_CONNECTION = "delete-app-connection",
|
DELETE_APP_CONNECTION = "delete-app-connection",
|
||||||
CREATE_SHARED_SECRET = "create-shared-secret",
|
CREATE_SHARED_SECRET = "create-shared-secret",
|
||||||
DELETE_SHARED_SECRET = "delete-shared-secret",
|
DELETE_SHARED_SECRET = "delete-shared-secret",
|
||||||
READ_SHARED_SECRET = "read-shared-secret"
|
READ_SHARED_SECRET = "read-shared-secret",
|
||||||
|
GET_SECRET_SYNCS = "get-secret-syncs",
|
||||||
|
GET_SECRET_SYNC = "get-secret-sync",
|
||||||
|
CREATE_SECRET_SYNC = "create-secret-sync",
|
||||||
|
UPDATE_SECRET_SYNC = "update-secret-sync",
|
||||||
|
DELETE_SECRET_SYNC = "delete-secret-sync",
|
||||||
|
SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets",
|
||||||
|
SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets",
|
||||||
|
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserActorMetadata {
|
interface UserActorMetadata {
|
||||||
@ -1893,6 +1909,15 @@ interface GetAppConnectionsEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetAvailableAppConnectionsDetailsEvent {
|
||||||
|
type: EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS;
|
||||||
|
metadata: {
|
||||||
|
app?: AppConnection;
|
||||||
|
count: number;
|
||||||
|
connectionIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface GetAppConnectionEvent {
|
interface GetAppConnectionEvent {
|
||||||
type: EventType.GET_APP_CONNECTION;
|
type: EventType.GET_APP_CONNECTION;
|
||||||
metadata: {
|
metadata: {
|
||||||
@ -1946,6 +1971,78 @@ interface ReadSharedSecretEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetSecretSyncsEvent {
|
||||||
|
type: EventType.GET_SECRET_SYNCS;
|
||||||
|
metadata: {
|
||||||
|
destination?: SecretSync;
|
||||||
|
count: number;
|
||||||
|
syncIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetSecretSyncEvent {
|
||||||
|
type: EventType.GET_SECRET_SYNC;
|
||||||
|
metadata: {
|
||||||
|
destination: SecretSync;
|
||||||
|
syncId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateSecretSyncEvent {
|
||||||
|
type: EventType.CREATE_SECRET_SYNC;
|
||||||
|
metadata: Omit<TCreateSecretSyncDTO, "projectId"> & { syncId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateSecretSyncEvent {
|
||||||
|
type: EventType.UPDATE_SECRET_SYNC;
|
||||||
|
metadata: TUpdateSecretSyncDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteSecretSyncEvent {
|
||||||
|
type: EventType.DELETE_SECRET_SYNC;
|
||||||
|
metadata: TDeleteSecretSyncDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretSyncSyncSecretsEvent {
|
||||||
|
type: EventType.SECRET_SYNC_SYNC_SECRETS;
|
||||||
|
metadata: Pick<
|
||||||
|
TSecretSyncRaw,
|
||||||
|
"syncOptions" | "destinationConfig" | "destination" | "syncStatus" | "connectionId" | "folderId"
|
||||||
|
> & {
|
||||||
|
syncId: string;
|
||||||
|
syncMessage: string | null;
|
||||||
|
jobId: string;
|
||||||
|
jobRanAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretSyncImportSecretsEvent {
|
||||||
|
type: EventType.SECRET_SYNC_IMPORT_SECRETS;
|
||||||
|
metadata: Pick<
|
||||||
|
TSecretSyncRaw,
|
||||||
|
"syncOptions" | "destinationConfig" | "destination" | "importStatus" | "connectionId" | "folderId"
|
||||||
|
> & {
|
||||||
|
syncId: string;
|
||||||
|
importMessage: string | null;
|
||||||
|
jobId: string;
|
||||||
|
jobRanAt: Date;
|
||||||
|
importBehavior: SecretSyncImportBehavior;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretSyncRemoveSecretsEvent {
|
||||||
|
type: EventType.SECRET_SYNC_REMOVE_SECRETS;
|
||||||
|
metadata: Pick<
|
||||||
|
TSecretSyncRaw,
|
||||||
|
"syncOptions" | "destinationConfig" | "destination" | "removeStatus" | "connectionId" | "folderId"
|
||||||
|
> & {
|
||||||
|
syncId: string;
|
||||||
|
removeMessage: string | null;
|
||||||
|
jobId: string;
|
||||||
|
jobRanAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
| GetSecretsEvent
|
| GetSecretsEvent
|
||||||
| GetSecretEvent
|
| GetSecretEvent
|
||||||
@ -2119,10 +2216,19 @@ export type Event =
|
|||||||
| DeleteProjectTemplateEvent
|
| DeleteProjectTemplateEvent
|
||||||
| ApplyProjectTemplateEvent
|
| ApplyProjectTemplateEvent
|
||||||
| GetAppConnectionsEvent
|
| GetAppConnectionsEvent
|
||||||
|
| GetAvailableAppConnectionsDetailsEvent
|
||||||
| GetAppConnectionEvent
|
| GetAppConnectionEvent
|
||||||
| CreateAppConnectionEvent
|
| CreateAppConnectionEvent
|
||||||
| UpdateAppConnectionEvent
|
| UpdateAppConnectionEvent
|
||||||
| DeleteAppConnectionEvent
|
| DeleteAppConnectionEvent
|
||||||
| CreateSharedSecretEvent
|
| CreateSharedSecretEvent
|
||||||
| DeleteSharedSecretEvent
|
| DeleteSharedSecretEvent
|
||||||
| ReadSharedSecretEvent;
|
| ReadSharedSecretEvent
|
||||||
|
| GetSecretSyncsEvent
|
||||||
|
| GetSecretSyncEvent
|
||||||
|
| CreateSecretSyncEvent
|
||||||
|
| UpdateSecretSyncEvent
|
||||||
|
| DeleteSecretSyncEvent
|
||||||
|
| SecretSyncSyncSecretsEvent
|
||||||
|
| SecretSyncImportSecretsEvent
|
||||||
|
| SecretSyncRemoveSecretsEvent;
|
||||||
|
@ -50,8 +50,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
|||||||
},
|
},
|
||||||
pkiEst: false,
|
pkiEst: false,
|
||||||
enforceMfa: false,
|
enforceMfa: false,
|
||||||
projectTemplates: false,
|
projectTemplates: false
|
||||||
appConnections: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
|
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
|
||||||
|
@ -68,7 +68,6 @@ export type TFeatureSet = {
|
|||||||
pkiEst: boolean;
|
pkiEst: boolean;
|
||||||
enforceMfa: boolean;
|
enforceMfa: boolean;
|
||||||
projectTemplates: false;
|
projectTemplates: false;
|
||||||
appConnections: false; // TODO: remove once live
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TOrgPlansTableDTO = {
|
export type TOrgPlansTableDTO = {
|
||||||
|
@ -1,4 +1,12 @@
|
|||||||
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";
|
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CASL_ACTION_SCHEMA_ENUM,
|
||||||
|
CASL_ACTION_SCHEMA_NATIVE_ENUM
|
||||||
|
} from "@app/ee/services/permission/permission-schemas";
|
||||||
|
import { PermissionConditionSchema } from "@app/ee/services/permission/permission-types";
|
||||||
|
import { PermissionConditionOperators } from "@app/lib/casl";
|
||||||
|
|
||||||
export enum OrgPermissionActions {
|
export enum OrgPermissionActions {
|
||||||
Read = "read",
|
Read = "read",
|
||||||
@ -7,6 +15,14 @@ export enum OrgPermissionActions {
|
|||||||
Delete = "delete"
|
Delete = "delete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum OrgPermissionAppConnectionActions {
|
||||||
|
Read = "read",
|
||||||
|
Create = "create",
|
||||||
|
Edit = "edit",
|
||||||
|
Delete = "delete",
|
||||||
|
Connect = "connect"
|
||||||
|
}
|
||||||
|
|
||||||
export enum OrgPermissionAdminConsoleAction {
|
export enum OrgPermissionAdminConsoleAction {
|
||||||
AccessAllProjects = "access-all-projects"
|
AccessAllProjects = "access-all-projects"
|
||||||
}
|
}
|
||||||
@ -31,6 +47,10 @@ export enum OrgPermissionSubjects {
|
|||||||
AppConnections = "app-connections"
|
AppConnections = "app-connections"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppConnectionSubjectFields = {
|
||||||
|
connectionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type OrgPermissionSet =
|
export type OrgPermissionSet =
|
||||||
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
|
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Role]
|
| [OrgPermissionActions, OrgPermissionSubjects.Role]
|
||||||
@ -47,9 +67,109 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.AppConnections]
|
| [
|
||||||
|
OrgPermissionAppConnectionActions,
|
||||||
|
(
|
||||||
|
| OrgPermissionSubjects.AppConnections
|
||||||
|
| (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields)
|
||||||
|
)
|
||||||
|
]
|
||||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
||||||
|
|
||||||
|
const AppConnectionConditionSchema = z
|
||||||
|
.object({
|
||||||
|
connectionId: z.union([
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||||
|
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||||
|
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
|
|
||||||
|
export const OrgPermissionSchema = z.discriminatedUnion("subject", [
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Workspace).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_ENUM([OrgPermissionActions.Create]).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Member).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Settings).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.IncidentAccount).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Sso).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Scim).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Groups).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.SecretScanning).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Billing).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Identity).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.Kms).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.AuditLogs).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.ProjectTemplates).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.AppConnections).describe("The entity this permission pertains to."),
|
||||||
|
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAppConnectionActions).describe(
|
||||||
|
"Describe what action an entity can take."
|
||||||
|
),
|
||||||
|
conditions: AppConnectionConditionSchema.describe(
|
||||||
|
"When specified, only matching conditions will be allowed to access given resource."
|
||||||
|
).optional()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(OrgPermissionSubjects.AdminConsole).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAdminConsoleAction).describe(
|
||||||
|
"Describe what action an entity can take."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
const buildAdminPermission = () => {
|
const buildAdminPermission = () => {
|
||||||
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
|
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
|
||||||
// ws permissions
|
// ws permissions
|
||||||
@ -125,10 +245,11 @@ const buildAdminPermission = () => {
|
|||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates);
|
||||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates);
|
can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates);
|
||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
can(OrgPermissionAppConnectionActions.Read, OrgPermissionSubjects.AppConnections);
|
||||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections);
|
can(OrgPermissionAppConnectionActions.Create, OrgPermissionSubjects.AppConnections);
|
||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections);
|
can(OrgPermissionAppConnectionActions.Edit, OrgPermissionSubjects.AppConnections);
|
||||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections);
|
can(OrgPermissionAppConnectionActions.Delete, OrgPermissionSubjects.AppConnections);
|
||||||
|
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
|
||||||
|
|
||||||
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
|
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
|
||||||
|
|
||||||
@ -160,7 +281,7 @@ const buildMemberPermission = () => {
|
|||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
9
backend/src/ee/services/permission/permission-schemas.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const CASL_ACTION_SCHEMA_NATIVE_ENUM = <ACTION extends z.EnumLike>(actions: ACTION) =>
|
||||||
|
z
|
||||||
|
.union([z.nativeEnum(actions), z.nativeEnum(actions).array().min(1)])
|
||||||
|
.transform((el) => (typeof el === "string" ? [el] : el));
|
||||||
|
|
||||||
|
export const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) =>
|
||||||
|
z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el));
|
@ -1,6 +1,10 @@
|
|||||||
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
|
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CASL_ACTION_SCHEMA_ENUM,
|
||||||
|
CASL_ACTION_SCHEMA_NATIVE_ENUM
|
||||||
|
} from "@app/ee/services/permission/permission-schemas";
|
||||||
import { conditionsMatcher, PermissionConditionOperators } from "@app/lib/casl";
|
import { conditionsMatcher, PermissionConditionOperators } from "@app/lib/casl";
|
||||||
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
|
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
|
||||||
|
|
||||||
@ -30,6 +34,16 @@ export enum ProjectPermissionDynamicSecretActions {
|
|||||||
Lease = "lease"
|
Lease = "lease"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectPermissionSecretSyncActions {
|
||||||
|
Read = "read",
|
||||||
|
Create = "create",
|
||||||
|
Edit = "edit",
|
||||||
|
Delete = "delete",
|
||||||
|
SyncSecrets = "sync-secrets",
|
||||||
|
ImportSecrets = "import-secrets",
|
||||||
|
RemoveSecrets = "remove-secrets"
|
||||||
|
}
|
||||||
|
|
||||||
export enum ProjectPermissionSub {
|
export enum ProjectPermissionSub {
|
||||||
Role = "role",
|
Role = "role",
|
||||||
Member = "member",
|
Member = "member",
|
||||||
@ -60,7 +74,8 @@ export enum ProjectPermissionSub {
|
|||||||
PkiAlerts = "pki-alerts",
|
PkiAlerts = "pki-alerts",
|
||||||
PkiCollections = "pki-collections",
|
PkiCollections = "pki-collections",
|
||||||
Kms = "kms",
|
Kms = "kms",
|
||||||
Cmek = "cmek"
|
Cmek = "cmek",
|
||||||
|
SecretSyncs = "secret-syncs"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SecretSubjectFields = {
|
export type SecretSubjectFields = {
|
||||||
@ -140,6 +155,7 @@ export type ProjectPermissionSet =
|
|||||||
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
|
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
|
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
|
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
|
||||||
|
| [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs]
|
||||||
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
|
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
|
||||||
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
|
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
|
||||||
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
|
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
|
||||||
@ -147,14 +163,6 @@ export type ProjectPermissionSet =
|
|||||||
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
|
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
|
||||||
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms];
|
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms];
|
||||||
|
|
||||||
const CASL_ACTION_SCHEMA_NATIVE_ENUM = <ACTION extends z.EnumLike>(actions: ACTION) =>
|
|
||||||
z
|
|
||||||
.union([z.nativeEnum(actions), z.nativeEnum(actions).array().min(1)])
|
|
||||||
.transform((el) => (typeof el === "string" ? [el] : el));
|
|
||||||
|
|
||||||
const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) =>
|
|
||||||
z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el));
|
|
||||||
|
|
||||||
// akhilmhdh: don't modify this for v2
|
// akhilmhdh: don't modify this for v2
|
||||||
// if you want to update create a new schema
|
// if you want to update create a new schema
|
||||||
const SecretConditionV1Schema = z
|
const SecretConditionV1Schema = z
|
||||||
@ -392,10 +400,15 @@ const GeneralPermissionSchema = [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
subject: z.literal(ProjectPermissionSub.Cmek).describe("The entity this permission pertains to."),
|
subject: z.literal(ProjectPermissionSub.Cmek).describe("The entity this permission pertains to."),
|
||||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
|
||||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCmekActions).describe(
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCmekActions).describe(
|
||||||
"Describe what action an entity can take."
|
"Describe what action an entity can take."
|
||||||
)
|
)
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe(
|
||||||
|
"Describe what action an entity can take."
|
||||||
|
)
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -549,6 +562,18 @@ const buildAdminPermissionRules = () => {
|
|||||||
],
|
],
|
||||||
ProjectPermissionSub.Cmek
|
ProjectPermissionSub.Cmek
|
||||||
);
|
);
|
||||||
|
can(
|
||||||
|
[
|
||||||
|
ProjectPermissionSecretSyncActions.Create,
|
||||||
|
ProjectPermissionSecretSyncActions.Edit,
|
||||||
|
ProjectPermissionSecretSyncActions.Delete,
|
||||||
|
ProjectPermissionSecretSyncActions.Read,
|
||||||
|
ProjectPermissionSecretSyncActions.SyncSecrets,
|
||||||
|
ProjectPermissionSecretSyncActions.ImportSecrets,
|
||||||
|
ProjectPermissionSecretSyncActions.RemoveSecrets
|
||||||
|
],
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -713,6 +738,19 @@ const buildMemberPermissionRules = () => {
|
|||||||
ProjectPermissionSub.Cmek
|
ProjectPermissionSub.Cmek
|
||||||
);
|
);
|
||||||
|
|
||||||
|
can(
|
||||||
|
[
|
||||||
|
ProjectPermissionSecretSyncActions.Create,
|
||||||
|
ProjectPermissionSecretSyncActions.Edit,
|
||||||
|
ProjectPermissionSecretSyncActions.Delete,
|
||||||
|
ProjectPermissionSecretSyncActions.Read,
|
||||||
|
ProjectPermissionSecretSyncActions.SyncSecrets,
|
||||||
|
ProjectPermissionSecretSyncActions.ImportSecrets,
|
||||||
|
ProjectPermissionSecretSyncActions.RemoveSecrets
|
||||||
|
],
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -746,6 +784,7 @@ const buildViewerPermissionRules = () => {
|
|||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
|
||||||
|
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
@ -23,6 +23,8 @@ export const KeyStorePrefixes = {
|
|||||||
`sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const,
|
`sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const,
|
||||||
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
|
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
|
||||||
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
|
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
|
||||||
|
SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const,
|
||||||
|
SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const,
|
||||||
IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) =>
|
IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) =>
|
||||||
`identity-access-token-status:${identityAccessTokenId}`,
|
`identity-access-token-status:${identityAccessTokenId}`,
|
||||||
ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`
|
ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`
|
||||||
@ -30,6 +32,7 @@ export const KeyStorePrefixes = {
|
|||||||
|
|
||||||
export const KeyStoreTtls = {
|
export const KeyStoreTtls = {
|
||||||
SetSyncSecretIntegrationLastRunTimestampInSeconds: 60,
|
SetSyncSecretIntegrationLastRunTimestampInSeconds: 60,
|
||||||
|
SetSecretSyncLastRunTimestampInSeconds: 60,
|
||||||
AccessTokenStatusUpdateInSeconds: 120
|
AccessTokenStatusUpdateInSeconds: 120
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
|
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||||
|
|
||||||
export const GROUPS = {
|
export const GROUPS = {
|
||||||
CREATE: {
|
CREATE: {
|
||||||
@ -1643,6 +1645,83 @@ export const AppConnections = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
DELETE: (app: AppConnection) => ({
|
DELETE: (app: AppConnection) => ({
|
||||||
connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} connection to be deleted.`
|
connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.`
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SecretSyncs = {
|
||||||
|
LIST: (destination?: SecretSync) => ({
|
||||||
|
projectId: `The ID of the project to list ${destination ? SECRET_SYNC_NAME_MAP[destination] : "Secret"} Syncs from.`
|
||||||
|
}),
|
||||||
|
GET_BY_ID: (destination: SecretSync) => ({
|
||||||
|
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to retrieve.`
|
||||||
|
}),
|
||||||
|
GET_BY_NAME: (destination: SecretSync) => ({
|
||||||
|
syncName: `The name of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to retrieve.`,
|
||||||
|
projectId: `The ID of the project the ${SECRET_SYNC_NAME_MAP[destination]} Sync is associated with.`
|
||||||
|
}),
|
||||||
|
CREATE: (destination: SecretSync) => {
|
||||||
|
const destinationName = SECRET_SYNC_NAME_MAP[destination];
|
||||||
|
return {
|
||||||
|
name: `The name of the ${destinationName} Sync to create. Must be slug-friendly.`,
|
||||||
|
description: `An optional description for the ${destinationName} Sync.`,
|
||||||
|
projectId: "The ID of the project to create the sync in.",
|
||||||
|
environment: `The slug of the project environment to sync secrets from.`,
|
||||||
|
secretPath: `The folder path to sync secrets from.`,
|
||||||
|
connectionId: `The ID of the ${
|
||||||
|
APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]]
|
||||||
|
} Connection to use for syncing.`,
|
||||||
|
isAutoSyncEnabled: `Whether secrets should be automatically synced when changes occur at the source location or not.`,
|
||||||
|
syncOptions: "Optional parameters to modify how secrets are synced."
|
||||||
|
};
|
||||||
|
},
|
||||||
|
UPDATE: (destination: SecretSync) => {
|
||||||
|
const destinationName = SECRET_SYNC_NAME_MAP[destination];
|
||||||
|
return {
|
||||||
|
syncId: `The ID of the ${destinationName} Sync to be updated.`,
|
||||||
|
connectionId: `The updated ID of the ${
|
||||||
|
APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]]
|
||||||
|
} Connection to use for syncing.`,
|
||||||
|
name: `The updated name of the ${destinationName} Sync. Must be slug-friendly.`,
|
||||||
|
environment: `The updated slug of the project environment to sync secrets from.`,
|
||||||
|
secretPath: `The updated folder path to sync secrets from.`,
|
||||||
|
description: `The updated description of the ${destinationName} Sync.`,
|
||||||
|
isAutoSyncEnabled: `Whether secrets should be automatically synced when changes occur at the source location or not.`,
|
||||||
|
syncOptions: "Optional parameters to modify how secrets are synced."
|
||||||
|
};
|
||||||
|
},
|
||||||
|
DELETE: (destination: SecretSync) => ({
|
||||||
|
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to be deleted.`,
|
||||||
|
removeSecrets: `Whether previously synced secrets should be removed prior to deletion.`
|
||||||
|
}),
|
||||||
|
SYNC_SECRETS: (destination: SecretSync) => ({
|
||||||
|
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger a sync for.`
|
||||||
|
}),
|
||||||
|
IMPORT_SECRETS: (destination: SecretSync) => ({
|
||||||
|
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger importing secrets for.`,
|
||||||
|
importBehavior: `Specify whether Infisical should prioritize secret values from Infisical or ${SECRET_SYNC_NAME_MAP[destination]}.`
|
||||||
|
}),
|
||||||
|
REMOVE_SECRETS: (destination: SecretSync) => ({
|
||||||
|
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger removing secrets for.`
|
||||||
|
}),
|
||||||
|
SYNC_OPTIONS: (destination: SecretSync) => {
|
||||||
|
const destinationName = SECRET_SYNC_NAME_MAP[destination];
|
||||||
|
return {
|
||||||
|
INITIAL_SYNC_BEHAVIOR: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.`,
|
||||||
|
PREPEND_PREFIX: `Optionally prepend a prefix to your secrets' keys when syncing to ${destinationName}.`,
|
||||||
|
APPEND_SUFFIX: `Optionally append a suffix to your secrets' keys when syncing to ${destinationName}.`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
DESTINATION_CONFIG: {
|
||||||
|
AWS_PARAMETER_STORE: {
|
||||||
|
REGION: "The AWS region to sync secrets to.",
|
||||||
|
PATH: "The Parameter Store path to sync secrets to."
|
||||||
|
},
|
||||||
|
GITHUB: {
|
||||||
|
ORG: "The name of the GitHub organization.",
|
||||||
|
OWNER: "The name of the GitHub account owner of the repository.",
|
||||||
|
REPO: "The name of the GitHub repository.",
|
||||||
|
ENV: "The name of the GitHub environment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -15,6 +15,12 @@ import {
|
|||||||
TIntegrationSyncPayload,
|
TIntegrationSyncPayload,
|
||||||
TSyncSecretsDTO
|
TSyncSecretsDTO
|
||||||
} from "@app/services/secret/secret-types";
|
} from "@app/services/secret/secret-types";
|
||||||
|
import {
|
||||||
|
TQueueSecretSyncImportSecretsByIdDTO,
|
||||||
|
TQueueSecretSyncRemoveSecretsByIdDTO,
|
||||||
|
TQueueSecretSyncSyncSecretsByIdDTO,
|
||||||
|
TQueueSendSecretSyncActionFailedNotificationsDTO
|
||||||
|
} from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
export enum QueueName {
|
export enum QueueName {
|
||||||
SecretRotation = "secret-rotation",
|
SecretRotation = "secret-rotation",
|
||||||
@ -36,7 +42,8 @@ export enum QueueName {
|
|||||||
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
|
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
|
||||||
ProjectV3Migration = "project-v3-migration",
|
ProjectV3Migration = "project-v3-migration",
|
||||||
AccessTokenStatusUpdate = "access-token-status-update",
|
AccessTokenStatusUpdate = "access-token-status-update",
|
||||||
ImportSecretsFromExternalSource = "import-secrets-from-external-source"
|
ImportSecretsFromExternalSource = "import-secrets-from-external-source",
|
||||||
|
AppConnectionSecretSync = "app-connection-secret-sync"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJobs {
|
export enum QueueJobs {
|
||||||
@ -61,7 +68,11 @@ export enum QueueJobs {
|
|||||||
ProjectV3Migration = "project-v3-migration",
|
ProjectV3Migration = "project-v3-migration",
|
||||||
IdentityAccessTokenStatusUpdate = "identity-access-token-status-update",
|
IdentityAccessTokenStatusUpdate = "identity-access-token-status-update",
|
||||||
ServiceTokenStatusUpdate = "service-token-status-update",
|
ServiceTokenStatusUpdate = "service-token-status-update",
|
||||||
ImportSecretsFromExternalSource = "import-secrets-from-external-source"
|
ImportSecretsFromExternalSource = "import-secrets-from-external-source",
|
||||||
|
SecretSyncSyncSecrets = "secret-sync-sync-secrets",
|
||||||
|
SecretSyncImportSecrets = "secret-sync-import-secrets",
|
||||||
|
SecretSyncRemoveSecrets = "secret-sync-remove-secrets",
|
||||||
|
SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TQueueJobTypes = {
|
export type TQueueJobTypes = {
|
||||||
@ -184,6 +195,23 @@ export type TQueueJobTypes = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
[QueueName.AppConnectionSecretSync]:
|
||||||
|
| {
|
||||||
|
name: QueueJobs.SecretSyncSyncSecrets;
|
||||||
|
payload: TQueueSecretSyncSyncSecretsByIdDTO;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: QueueJobs.SecretSyncImportSecrets;
|
||||||
|
payload: TQueueSecretSyncImportSecretsByIdDTO;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: QueueJobs.SecretSyncRemoveSecrets;
|
||||||
|
payload: TQueueSecretSyncRemoveSecretsByIdDTO;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: QueueJobs.SecretSyncSendActionFailedNotifications;
|
||||||
|
payload: TQueueSendSecretSyncActionFailedNotificationsDTO;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||||
|
@ -196,6 +196,9 @@ import { secretImportDALFactory } from "@app/services/secret-import/secret-impor
|
|||||||
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||||
import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal";
|
import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal";
|
||||||
import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
||||||
|
import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
|
||||||
|
import { secretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-queue";
|
||||||
|
import { secretSyncServiceFactory } from "@app/services/secret-sync/secret-sync-service";
|
||||||
import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||||
import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
||||||
import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
|
import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
@ -318,6 +321,7 @@ export const registerRoutes = async (
|
|||||||
const trustedIpDAL = trustedIpDALFactory(db);
|
const trustedIpDAL = trustedIpDALFactory(db);
|
||||||
const telemetryDAL = telemetryDALFactory(db);
|
const telemetryDAL = telemetryDALFactory(db);
|
||||||
const appConnectionDAL = appConnectionDALFactory(db);
|
const appConnectionDAL = appConnectionDALFactory(db);
|
||||||
|
const secretSyncDAL = secretSyncDALFactory(db, folderDAL);
|
||||||
|
|
||||||
// ee db layer ops
|
// ee db layer ops
|
||||||
const permissionDAL = permissionDALFactory(db);
|
const permissionDAL = permissionDALFactory(db);
|
||||||
@ -824,6 +828,29 @@ export const registerRoutes = async (
|
|||||||
kmsService
|
kmsService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const secretSyncQueue = secretSyncQueueFactory({
|
||||||
|
queueService,
|
||||||
|
secretSyncDAL,
|
||||||
|
folderDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
kmsService,
|
||||||
|
keyStore,
|
||||||
|
auditLogService,
|
||||||
|
smtpService,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
secretDAL,
|
||||||
|
secretBlindIndexDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
secretVersionV2BridgeDAL,
|
||||||
|
secretVersionTagV2BridgeDAL,
|
||||||
|
resourceMetadataDAL
|
||||||
|
});
|
||||||
|
|
||||||
const secretQueueService = secretQueueFactory({
|
const secretQueueService = secretQueueFactory({
|
||||||
keyStore,
|
keyStore,
|
||||||
queueService,
|
queueService,
|
||||||
@ -858,7 +885,8 @@ export const registerRoutes = async (
|
|||||||
projectKeyDAL,
|
projectKeyDAL,
|
||||||
projectUserMembershipRoleDAL,
|
projectUserMembershipRoleDAL,
|
||||||
orgService,
|
orgService,
|
||||||
resourceMetadataDAL
|
resourceMetadataDAL,
|
||||||
|
secretSyncQueue
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectService = projectServiceFactory({
|
const projectService = projectServiceFactory({
|
||||||
@ -1369,8 +1397,17 @@ export const registerRoutes = async (
|
|||||||
const appConnectionService = appConnectionServiceFactory({
|
const appConnectionService = appConnectionServiceFactory({
|
||||||
appConnectionDAL,
|
appConnectionDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
kmsService,
|
kmsService
|
||||||
licenseService
|
});
|
||||||
|
|
||||||
|
const secretSyncService = secretSyncServiceFactory({
|
||||||
|
secretSyncDAL,
|
||||||
|
permissionService,
|
||||||
|
appConnectionService,
|
||||||
|
folderDAL,
|
||||||
|
secretSyncQueue,
|
||||||
|
projectBotService,
|
||||||
|
keyStore
|
||||||
});
|
});
|
||||||
|
|
||||||
await superAdminService.initServerCfg();
|
await superAdminService.initServerCfg();
|
||||||
@ -1470,7 +1507,8 @@ export const registerRoutes = async (
|
|||||||
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
|
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
|
||||||
projectTemplate: projectTemplateService,
|
projectTemplate: projectTemplateService,
|
||||||
totp: totpService,
|
totp: totpService,
|
||||||
appConnection: appConnectionService
|
appConnection: appConnectionService,
|
||||||
|
secretSync: secretSyncService
|
||||||
});
|
});
|
||||||
|
|
||||||
const cronJobs: CronJob[] = [];
|
const cronJobs: CronJob[] = [];
|
||||||
|
@ -15,7 +15,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
app,
|
app,
|
||||||
createSchema,
|
createSchema,
|
||||||
updateSchema,
|
updateSchema,
|
||||||
responseSchema
|
sanitizedResponseSchema
|
||||||
}: {
|
}: {
|
||||||
app: AppConnection;
|
app: AppConnection;
|
||||||
server: FastifyZodProvider;
|
server: FastifyZodProvider;
|
||||||
@ -26,7 +26,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
}>;
|
}>;
|
||||||
updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>;
|
updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>;
|
||||||
responseSchema: z.ZodTypeAny;
|
sanitizedResponseSchema: z.ZodTypeAny;
|
||||||
}) => {
|
}) => {
|
||||||
const appName = APP_CONNECTION_NAME_MAP[app];
|
const appName = APP_CONNECTION_NAME_MAP[app];
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
schema: {
|
schema: {
|
||||||
description: `List the ${appName} Connections for the current organization.`,
|
description: `List the ${appName} Connections for the current organization.`,
|
||||||
response: {
|
response: {
|
||||||
200: z.object({ appConnections: responseSchema.array() })
|
200: z.object({ appConnections: sanitizedResponseSchema.array() })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
@ -63,6 +63,44 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/available",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `List the ${appName} Connections the current user has permission to establish connections with.`,
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
appConnections: z.object({ app: z.literal(app), name: z.string(), id: z.string().uuid() }).array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const appConnections = await server.services.appConnection.listAvailableAppConnectionsForUser(
|
||||||
|
app,
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
orgId: req.permission.orgId,
|
||||||
|
event: {
|
||||||
|
type: EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS,
|
||||||
|
metadata: {
|
||||||
|
app,
|
||||||
|
count: appConnections.length,
|
||||||
|
connectionIds: appConnections.map((connection) => connection.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { appConnections };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/:connectionId",
|
url: "/:connectionId",
|
||||||
@ -75,7 +113,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
connectionId: z.string().uuid().describe(AppConnections.GET_BY_ID(app).connectionId)
|
connectionId: z.string().uuid().describe(AppConnections.GET_BY_ID(app).connectionId)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({ appConnection: responseSchema })
|
200: z.object({ appConnection: sanitizedResponseSchema })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
@ -105,7 +143,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/name/:connectionName`,
|
url: `/connection-name/:connectionName`,
|
||||||
config: {
|
config: {
|
||||||
rateLimit: readLimit
|
rateLimit: readLimit
|
||||||
},
|
},
|
||||||
@ -114,11 +152,12 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
params: z.object({
|
params: z.object({
|
||||||
connectionName: z
|
connectionName: z
|
||||||
.string()
|
.string()
|
||||||
.min(0, "Connection name required")
|
.trim()
|
||||||
|
.min(1, "Connection name required")
|
||||||
.describe(AppConnections.GET_BY_NAME(app).connectionName)
|
.describe(AppConnections.GET_BY_NAME(app).connectionName)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({ appConnection: responseSchema })
|
200: z.object({ appConnection: sanitizedResponseSchema })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
@ -158,7 +197,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
} ${appName} Connection for the current organization.`,
|
} ${appName} Connection for the current organization.`,
|
||||||
body: createSchema,
|
body: createSchema,
|
||||||
response: {
|
response: {
|
||||||
200: z.object({ appConnection: responseSchema })
|
200: z.object({ appConnection: sanitizedResponseSchema })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
@ -168,7 +207,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
const appConnection = (await server.services.appConnection.createAppConnection(
|
const appConnection = (await server.services.appConnection.createAppConnection(
|
||||||
{ name, method, app, credentials, description },
|
{ name, method, app, credentials, description },
|
||||||
req.permission
|
req.permission
|
||||||
)) as TAppConnection;
|
)) as T;
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
await server.services.auditLog.createAuditLog({
|
||||||
...req.auditLogInfo,
|
...req.auditLogInfo,
|
||||||
@ -201,7 +240,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
}),
|
}),
|
||||||
body: updateSchema,
|
body: updateSchema,
|
||||||
response: {
|
response: {
|
||||||
200: z.object({ appConnection: responseSchema })
|
200: z.object({ appConnection: sanitizedResponseSchema })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
@ -244,7 +283,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
|
|||||||
connectionId: z.string().uuid().describe(AppConnections.DELETE(app).connectionId)
|
connectionId: z.string().uuid().describe(AppConnections.DELETE(app).connectionId)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({ appConnection: responseSchema })
|
200: z.object({ appConnection: sanitizedResponseSchema })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
@ -1,17 +0,0 @@
|
|||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
|
||||||
import {
|
|
||||||
CreateGitHubConnectionSchema,
|
|
||||||
SanitizedGitHubConnectionSchema,
|
|
||||||
UpdateGitHubConnectionSchema
|
|
||||||
} from "@app/services/app-connection/github";
|
|
||||||
|
|
||||||
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
|
||||||
|
|
||||||
export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) =>
|
|
||||||
registerAppConnectionEndpoints({
|
|
||||||
app: AppConnection.GitHub,
|
|
||||||
server,
|
|
||||||
responseSchema: SanitizedGitHubConnectionSchema,
|
|
||||||
createSchema: CreateGitHubConnectionSchema,
|
|
||||||
updateSchema: UpdateGitHubConnectionSchema
|
|
||||||
});
|
|
@ -1,8 +0,0 @@
|
|||||||
import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router";
|
|
||||||
import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router";
|
|
||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
|
||||||
|
|
||||||
export const APP_CONNECTION_REGISTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> = {
|
|
||||||
[AppConnection.AWS]: registerAwsConnectionRouter,
|
|
||||||
[AppConnection.GitHub]: registerGitHubConnectionRouter
|
|
||||||
};
|
|
@ -11,7 +11,7 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
|
|||||||
registerAppConnectionEndpoints({
|
registerAppConnectionEndpoints({
|
||||||
app: AppConnection.AWS,
|
app: AppConnection.AWS,
|
||||||
server,
|
server,
|
||||||
responseSchema: SanitizedAwsConnectionSchema,
|
sanitizedResponseSchema: SanitizedAwsConnectionSchema,
|
||||||
createSchema: CreateAwsConnectionSchema,
|
createSchema: CreateAwsConnectionSchema,
|
||||||
updateSchema: UpdateAwsConnectionSchema
|
updateSchema: UpdateAwsConnectionSchema
|
||||||
});
|
});
|
@ -0,0 +1,117 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { readLimit } from "@app/server/config/rateLimiter";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import {
|
||||||
|
CreateGitHubConnectionSchema,
|
||||||
|
SanitizedGitHubConnectionSchema,
|
||||||
|
UpdateGitHubConnectionSchema
|
||||||
|
} from "@app/services/app-connection/github";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
||||||
|
|
||||||
|
export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => {
|
||||||
|
registerAppConnectionEndpoints({
|
||||||
|
app: AppConnection.GitHub,
|
||||||
|
server,
|
||||||
|
sanitizedResponseSchema: SanitizedGitHubConnectionSchema,
|
||||||
|
createSchema: CreateGitHubConnectionSchema,
|
||||||
|
updateSchema: UpdateGitHubConnectionSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
// The below endpoints are not exposed and for Infisical App use
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `/:connectionId/repositories`,
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
connectionId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
repositories: z
|
||||||
|
.object({ id: z.number(), name: z.string(), owner: z.object({ login: z.string(), id: z.number() }) })
|
||||||
|
.array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { connectionId } = req.params;
|
||||||
|
|
||||||
|
const repositories = await server.services.appConnection.github.listRepositories(connectionId, req.permission);
|
||||||
|
|
||||||
|
return { repositories };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `/:connectionId/organizations`,
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
connectionId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
organizations: z.object({ id: z.number(), login: z.string() }).array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { connectionId } = req.params;
|
||||||
|
|
||||||
|
const organizations = await server.services.appConnection.github.listOrganizations(connectionId, req.permission);
|
||||||
|
|
||||||
|
return { organizations };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `/:connectionId/environments`,
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
connectionId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
repo: z.string().min(1, "Repository name is required"),
|
||||||
|
owner: z.string().min(1, "Repository owner name is required")
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
environments: z.object({ id: z.number(), name: z.string() }).array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { connectionId } = req.params;
|
||||||
|
const { repo, owner } = req.query;
|
||||||
|
|
||||||
|
const environments = await server.services.appConnection.github.listEnvironments(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
repo,
|
||||||
|
owner
|
||||||
|
},
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
return { environments };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -1,2 +1,12 @@
|
|||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
|
||||||
|
import { registerAwsConnectionRouter } from "./aws-connection-router";
|
||||||
|
import { registerGitHubConnectionRouter } from "./github-connection-router";
|
||||||
|
|
||||||
export * from "./app-connection-router";
|
export * from "./app-connection-router";
|
||||||
export * from "./apps";
|
|
||||||
|
export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> =
|
||||||
|
{
|
||||||
|
[AppConnection.AWS]: registerAwsConnectionRouter,
|
||||||
|
[AppConnection.GitHub]: registerGitHubConnectionRouter
|
||||||
|
};
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "@app/server/routes/v1/app-connection-routers";
|
import {
|
||||||
|
APP_CONNECTION_REGISTER_ROUTER_MAP,
|
||||||
|
registerAppConnectionRouter
|
||||||
|
} from "@app/server/routes/v1/app-connection-routers";
|
||||||
import { registerCmekRouter } from "@app/server/routes/v1/cmek-router";
|
import { registerCmekRouter } from "@app/server/routes/v1/cmek-router";
|
||||||
import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router";
|
import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router";
|
||||||
|
import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/server/routes/v1/secret-sync-routers";
|
||||||
|
|
||||||
import { registerAdminRouter } from "./admin-router";
|
import { registerAdminRouter } from "./admin-router";
|
||||||
import { registerAuthRoutes } from "./auth-router";
|
import { registerAuthRoutes } from "./auth-router";
|
||||||
@ -113,12 +117,28 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
|||||||
await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" });
|
await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" });
|
||||||
|
|
||||||
await server.register(
|
await server.register(
|
||||||
async (appConnectionsRouter) => {
|
async (appConnectionRouter) => {
|
||||||
await appConnectionsRouter.register(registerAppConnectionRouter);
|
// register generic app connection endpoints
|
||||||
for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_MAP)) {
|
await appConnectionRouter.register(registerAppConnectionRouter);
|
||||||
await appConnectionsRouter.register(router, { prefix: `/${app}` });
|
|
||||||
|
// register service specific endpoints (app-connections/aws, app-connections/github, etc.)
|
||||||
|
for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_ROUTER_MAP)) {
|
||||||
|
await appConnectionRouter.register(router, { prefix: `/${app}` });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ prefix: "/app-connections" }
|
{ prefix: "/app-connections" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await server.register(
|
||||||
|
async (secretSyncRouter) => {
|
||||||
|
// register generic secret sync endpoints
|
||||||
|
await secretSyncRouter.register(registerSecretSyncRouter);
|
||||||
|
|
||||||
|
// register service specific secret sync endpoints (secret-syncs/aws-parameter-store, secret-syncs/github, etc.)
|
||||||
|
for await (const [destination, router] of Object.entries(SECRET_SYNC_REGISTER_ROUTER_MAP)) {
|
||||||
|
await secretSyncRouter.register(router, { prefix: `/${destination}` });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ prefix: "/secret-syncs" }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
AwsParameterStoreSyncSchema,
|
||||||
|
CreateAwsParameterStoreSyncSchema,
|
||||||
|
UpdateAwsParameterStoreSyncSchema
|
||||||
|
} from "@app/services/secret-sync/aws-parameter-store";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
|
||||||
|
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||||
|
|
||||||
|
export const registerAwsParameterStoreSyncRouter = async (server: FastifyZodProvider) =>
|
||||||
|
registerSyncSecretsEndpoints({
|
||||||
|
destination: SecretSync.AWSParameterStore,
|
||||||
|
server,
|
||||||
|
responseSchema: AwsParameterStoreSyncSchema,
|
||||||
|
createSchema: CreateAwsParameterStoreSyncSchema,
|
||||||
|
updateSchema: UpdateAwsParameterStoreSyncSchema
|
||||||
|
});
|
@ -0,0 +1,13 @@
|
|||||||
|
import { CreateGitHubSyncSchema, GitHubSyncSchema, UpdateGitHubSyncSchema } from "@app/services/secret-sync/github";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
|
||||||
|
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||||
|
|
||||||
|
export const registerGitHubSyncRouter = async (server: FastifyZodProvider) =>
|
||||||
|
registerSyncSecretsEndpoints({
|
||||||
|
destination: SecretSync.GitHub,
|
||||||
|
server,
|
||||||
|
responseSchema: GitHubSyncSchema,
|
||||||
|
createSchema: CreateGitHubSyncSchema,
|
||||||
|
updateSchema: UpdateGitHubSyncSchema
|
||||||
|
});
|
11
backend/src/server/routes/v1/secret-sync-routers/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
|
||||||
|
import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router";
|
||||||
|
import { registerGitHubSyncRouter } from "./github-sync-router";
|
||||||
|
|
||||||
|
export * from "./secret-sync-router";
|
||||||
|
|
||||||
|
export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: FastifyZodProvider) => Promise<void>> = {
|
||||||
|
[SecretSync.AWSParameterStore]: registerAwsParameterStoreSyncRouter,
|
||||||
|
[SecretSync.GitHub]: registerGitHubSyncRouter
|
||||||
|
};
|
@ -0,0 +1,408 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { SecretSyncs } from "@app/lib/api-docs";
|
||||||
|
import { startsWithVowel } from "@app/lib/fn";
|
||||||
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||||
|
import { TSecretSync, TSecretSyncInput } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TSecretSyncInput>({
|
||||||
|
server,
|
||||||
|
destination,
|
||||||
|
createSchema,
|
||||||
|
updateSchema,
|
||||||
|
responseSchema
|
||||||
|
}: {
|
||||||
|
destination: SecretSync;
|
||||||
|
server: FastifyZodProvider;
|
||||||
|
createSchema: z.ZodType<{
|
||||||
|
name: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
projectId: string;
|
||||||
|
connectionId: string;
|
||||||
|
destinationConfig: I["destinationConfig"];
|
||||||
|
syncOptions: I["syncOptions"];
|
||||||
|
description?: string | null;
|
||||||
|
isAutoSyncEnabled?: boolean;
|
||||||
|
}>;
|
||||||
|
updateSchema: z.ZodType<{
|
||||||
|
connectionId?: string;
|
||||||
|
name?: string;
|
||||||
|
environment?: string;
|
||||||
|
secretPath?: string;
|
||||||
|
destinationConfig?: I["destinationConfig"];
|
||||||
|
syncOptions?: I["syncOptions"];
|
||||||
|
description?: string | null;
|
||||||
|
isAutoSyncEnabled?: boolean;
|
||||||
|
}>;
|
||||||
|
responseSchema: z.ZodTypeAny;
|
||||||
|
}) => {
|
||||||
|
const destinationName = SECRET_SYNC_NAME_MAP[destination];
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `/`,
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `List the ${destinationName} Syncs for the specified project.`,
|
||||||
|
querystring: z.object({
|
||||||
|
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST(destination).projectId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSyncs: responseSchema.array() })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const {
|
||||||
|
query: { projectId }
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
const secretSyncs = (await server.services.secretSync.listSecretSyncsByProjectId(
|
||||||
|
{ projectId, destination },
|
||||||
|
req.permission
|
||||||
|
)) as T[];
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.GET_SECRET_SYNCS,
|
||||||
|
metadata: {
|
||||||
|
destination,
|
||||||
|
count: secretSyncs.length,
|
||||||
|
syncIds: secretSyncs.map((connection) => connection.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSyncs };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/:syncId",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Get the specified ${destinationName} Sync by ID.`,
|
||||||
|
params: z.object({
|
||||||
|
syncId: z.string().uuid().describe(SecretSyncs.GET_BY_ID(destination).syncId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncId } = req.params;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.findSecretSyncById(
|
||||||
|
{ syncId, destination },
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.GET_SECRET_SYNC,
|
||||||
|
metadata: {
|
||||||
|
syncId,
|
||||||
|
destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `/sync-name/:syncName`,
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Get the specified ${destinationName} Sync by name and project ID.`,
|
||||||
|
params: z.object({
|
||||||
|
syncName: z.string().trim().min(1, "Sync name required").describe(SecretSyncs.GET_BY_NAME(destination).syncName)
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
projectId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Project ID required")
|
||||||
|
.describe(SecretSyncs.GET_BY_NAME(destination).projectId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncName } = req.params;
|
||||||
|
const { projectId } = req.query;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.findSecretSyncByName(
|
||||||
|
{ syncName, projectId, destination },
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.GET_SECRET_SYNC,
|
||||||
|
metadata: {
|
||||||
|
syncId: secretSync.id,
|
||||||
|
destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Create ${
|
||||||
|
startsWithVowel(destinationName) ? "an" : "a"
|
||||||
|
} ${destinationName} Sync for the specified project environment.`,
|
||||||
|
body: createSchema,
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const secretSync = (await server.services.secretSync.createSecretSync(
|
||||||
|
{ ...req.body, destination },
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.CREATE_SECRET_SYNC,
|
||||||
|
metadata: {
|
||||||
|
syncId: secretSync.id,
|
||||||
|
destination,
|
||||||
|
...req.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/:syncId",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Update the specified ${destinationName} Sync.`,
|
||||||
|
params: z.object({
|
||||||
|
syncId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).syncId)
|
||||||
|
}),
|
||||||
|
body: updateSchema,
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncId } = req.params;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.updateSecretSync(
|
||||||
|
{ ...req.body, syncId, destination },
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.UPDATE_SECRET_SYNC,
|
||||||
|
metadata: {
|
||||||
|
syncId,
|
||||||
|
destination,
|
||||||
|
...req.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/:syncId`,
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Delete the specified ${destinationName} Sync.`,
|
||||||
|
params: z.object({
|
||||||
|
syncId: z.string().uuid().describe(SecretSyncs.DELETE(destination).syncId)
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
removeSecrets: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.default("false")
|
||||||
|
.transform((value) => value === "true")
|
||||||
|
.describe(SecretSyncs.DELETE(destination).removeSecrets)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncId } = req.params;
|
||||||
|
const { removeSecrets } = req.query;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.deleteSecretSync(
|
||||||
|
{ destination, syncId, removeSecrets },
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
orgId: req.permission.orgId,
|
||||||
|
event: {
|
||||||
|
type: EventType.DELETE_SECRET_SYNC,
|
||||||
|
metadata: {
|
||||||
|
destination,
|
||||||
|
syncId,
|
||||||
|
removeSecrets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/:syncId/sync-secrets",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Trigger a sync for the specified ${destinationName} Sync.`,
|
||||||
|
params: z.object({
|
||||||
|
syncId: z.string().uuid().describe(SecretSyncs.SYNC_SECRETS(destination).syncId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncId } = req.params;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.triggerSecretSyncSyncSecretsById(
|
||||||
|
{
|
||||||
|
syncId,
|
||||||
|
destination,
|
||||||
|
auditLogInfo: req.auditLogInfo
|
||||||
|
},
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/:syncId/import-secrets",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Import secrets from the specified ${destinationName} Sync destination.`,
|
||||||
|
params: z.object({
|
||||||
|
syncId: z.string().uuid().describe(SecretSyncs.IMPORT_SECRETS(destination).syncId)
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
importBehavior: z
|
||||||
|
.nativeEnum(SecretSyncImportBehavior)
|
||||||
|
.describe(SecretSyncs.IMPORT_SECRETS(destination).importBehavior)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncId } = req.params;
|
||||||
|
const { importBehavior } = req.query;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.triggerSecretSyncImportSecretsById(
|
||||||
|
{
|
||||||
|
syncId,
|
||||||
|
destination,
|
||||||
|
importBehavior
|
||||||
|
},
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/:syncId/remove-secrets",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: `Remove previously synced secrets from the specified ${destinationName} Sync destination.`,
|
||||||
|
params: z.object({
|
||||||
|
syncId: z.string().uuid().describe(SecretSyncs.REMOVE_SECRETS(destination).syncId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSync: responseSchema })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { syncId } = req.params;
|
||||||
|
|
||||||
|
const secretSync = (await server.services.secretSync.triggerSecretSyncRemoveSecretsById(
|
||||||
|
{
|
||||||
|
syncId,
|
||||||
|
destination
|
||||||
|
},
|
||||||
|
req.permission
|
||||||
|
)) as T;
|
||||||
|
|
||||||
|
return { secretSync };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,82 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { SecretSyncs } from "@app/lib/api-docs";
|
||||||
|
import { readLimit } from "@app/server/config/rateLimiter";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
import {
|
||||||
|
AwsParameterStoreSyncListItemSchema,
|
||||||
|
AwsParameterStoreSyncSchema
|
||||||
|
} from "@app/services/secret-sync/aws-parameter-store";
|
||||||
|
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
|
||||||
|
|
||||||
|
const SecretSyncSchema = z.discriminatedUnion("destination", [AwsParameterStoreSyncSchema, GitHubSyncSchema]);
|
||||||
|
|
||||||
|
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
|
||||||
|
AwsParameterStoreSyncListItemSchema,
|
||||||
|
GitHubSyncListItemSchema
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/options",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "List the available Secret Sync Options.",
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
secretSyncOptions: SecretSyncOptionsSchema.array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: () => {
|
||||||
|
const secretSyncOptions = server.services.secretSync.listSecretSyncOptions();
|
||||||
|
return { secretSyncOptions };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "List all the Secret Syncs for the specified project.",
|
||||||
|
querystring: z.object({
|
||||||
|
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST().projectId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({ secretSyncs: SecretSyncSchema.array() })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const {
|
||||||
|
query: { projectId },
|
||||||
|
permission
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
const secretSyncs = await server.services.secretSync.listSecretSyncsByProjectId({ projectId }, permission);
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.GET_SECRET_SYNCS,
|
||||||
|
metadata: {
|
||||||
|
syncIds: secretSyncs.map((sync) => sync.id),
|
||||||
|
count: secretSyncs.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { secretSyncs };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -2,3 +2,50 @@ export enum AppConnection {
|
|||||||
GitHub = "github",
|
GitHub = "github",
|
||||||
AWS = "aws"
|
AWS = "aws"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AWSRegion {
|
||||||
|
// US
|
||||||
|
US_EAST_1 = "us-east-1", // N. Virginia
|
||||||
|
US_EAST_2 = "us-east-2", // Ohio
|
||||||
|
US_WEST_1 = "us-west-1", // N. California
|
||||||
|
US_WEST_2 = "us-west-2", // Oregon
|
||||||
|
|
||||||
|
// GovCloud
|
||||||
|
US_GOV_EAST_1 = "us-gov-east-1", // US-East
|
||||||
|
US_GOV_WEST_1 = "us-gov-west-1", // US-West
|
||||||
|
|
||||||
|
// Africa
|
||||||
|
AF_SOUTH_1 = "af-south-1", // Cape Town
|
||||||
|
|
||||||
|
// Asia Pacific
|
||||||
|
AP_EAST_1 = "ap-east-1", // Hong Kong
|
||||||
|
AP_SOUTH_1 = "ap-south-1", // Mumbai
|
||||||
|
AP_SOUTH_2 = "ap-south-2", // Hyderabad
|
||||||
|
AP_NORTHEAST_1 = "ap-northeast-1", // Tokyo
|
||||||
|
AP_NORTHEAST_2 = "ap-northeast-2", // Seoul
|
||||||
|
AP_NORTHEAST_3 = "ap-northeast-3", // Osaka
|
||||||
|
AP_SOUTHEAST_1 = "ap-southeast-1", // Singapore
|
||||||
|
AP_SOUTHEAST_2 = "ap-southeast-2", // Sydney
|
||||||
|
AP_SOUTHEAST_3 = "ap-southeast-3", // Jakarta
|
||||||
|
AP_SOUTHEAST_4 = "ap-southeast-4", // Melbourne
|
||||||
|
|
||||||
|
// Canada
|
||||||
|
CA_CENTRAL_1 = "ca-central-1", // Central
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
EU_CENTRAL_1 = "eu-central-1", // Frankfurt
|
||||||
|
EU_CENTRAL_2 = "eu-central-2", // Zurich
|
||||||
|
EU_WEST_1 = "eu-west-1", // Ireland
|
||||||
|
EU_WEST_2 = "eu-west-2", // London
|
||||||
|
EU_WEST_3 = "eu-west-3", // Paris
|
||||||
|
EU_SOUTH_1 = "eu-south-1", // Milan
|
||||||
|
EU_SOUTH_2 = "eu-south-2", // Spain
|
||||||
|
EU_NORTH_1 = "eu-north-1", // Stockholm
|
||||||
|
|
||||||
|
// Middle East
|
||||||
|
ME_SOUTH_1 = "me-south-1", // Bahrain
|
||||||
|
ME_CENTRAL_1 = "me-central-1", // UAE
|
||||||
|
|
||||||
|
// South America
|
||||||
|
SA_EAST_1 = "sa-east-1" // Sao Paulo
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TAppConnections } from "@app/db/schemas/app-connections";
|
||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service";
|
import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service";
|
||||||
import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types";
|
import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types";
|
||||||
@ -64,9 +65,8 @@ export const validateAppConnectionCredentials = async (
|
|||||||
): Promise<TAppConnection["credentials"]> => {
|
): Promise<TAppConnection["credentials"]> => {
|
||||||
const { app } = appConnection;
|
const { app } = appConnection;
|
||||||
switch (app) {
|
switch (app) {
|
||||||
case AppConnection.AWS: {
|
case AppConnection.AWS:
|
||||||
return validateAwsConnectionCredentials(appConnection);
|
return validateAwsConnectionCredentials(appConnection);
|
||||||
}
|
|
||||||
case AppConnection.GitHub:
|
case AppConnection.GitHub:
|
||||||
return validateGitHubConnectionCredentials(appConnection);
|
return validateGitHubConnectionCredentials(appConnection);
|
||||||
default:
|
default:
|
||||||
@ -90,3 +90,17 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
|
|||||||
throw new Error(`Unhandled App Connection Method: ${method}`);
|
throw new Error(`Unhandled App Connection Method: ${method}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const decryptAppConnection = async (
|
||||||
|
appConnection: TAppConnections,
|
||||||
|
kmsService: TAppConnectionServiceFactoryDep["kmsService"]
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
...appConnection,
|
||||||
|
credentials: await decryptAppConnectionCredentials({
|
||||||
|
encryptedCredentials: appConnection.encryptedCredentials,
|
||||||
|
orgId: appConnection.orgId,
|
||||||
|
kmsService
|
||||||
|
})
|
||||||
|
} as TAppConnection;
|
||||||
|
};
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
|
||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||||
import { DiscriminativePick, OrgServiceActor } from "@app/lib/types";
|
import { DiscriminativePick, OrgServiceActor } from "@app/lib/types";
|
||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
import {
|
import {
|
||||||
decryptAppConnectionCredentials,
|
decryptAppConnection,
|
||||||
encryptAppConnectionCredentials,
|
encryptAppConnectionCredentials,
|
||||||
getAppConnectionMethodName,
|
getAppConnectionMethodName,
|
||||||
listAppConnectionOptions,
|
listAppConnectionOptions,
|
||||||
@ -23,6 +22,7 @@ import {
|
|||||||
} from "@app/services/app-connection/app-connection-types";
|
} from "@app/services/app-connection/app-connection-types";
|
||||||
import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws";
|
import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws";
|
||||||
import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github";
|
import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github";
|
||||||
|
import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service";
|
||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
|
|
||||||
import { TAppConnectionDALFactory } from "./app-connection-dal";
|
import { TAppConnectionDALFactory } from "./app-connection-dal";
|
||||||
@ -31,7 +31,6 @@ export type TAppConnectionServiceFactoryDep = {
|
|||||||
appConnectionDAL: TAppConnectionDALFactory;
|
appConnectionDAL: TAppConnectionDALFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">; // TODO: remove once launched
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>;
|
export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>;
|
||||||
@ -44,19 +43,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
|
|||||||
export const appConnectionServiceFactory = ({
|
export const appConnectionServiceFactory = ({
|
||||||
appConnectionDAL,
|
appConnectionDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
kmsService,
|
kmsService
|
||||||
licenseService
|
|
||||||
}: TAppConnectionServiceFactoryDep) => {
|
}: TAppConnectionServiceFactoryDep) => {
|
||||||
// app connections are disabled for public until launch
|
|
||||||
const checkAppServicesAvailability = async (orgId: string) => {
|
|
||||||
const subscription = await licenseService.getPlan(orgId);
|
|
||||||
|
|
||||||
if (!subscription.appConnections) throw new BadRequestError({ message: "App Connections are not available yet." });
|
|
||||||
};
|
|
||||||
|
|
||||||
const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => {
|
const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => {
|
||||||
await checkAppServicesAvailability(actor.orgId);
|
|
||||||
|
|
||||||
const { permission } = await permissionService.getOrgPermission(
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
actor.id,
|
actor.id,
|
||||||
@ -65,7 +54,10 @@ export const appConnectionServiceFactory = ({
|
|||||||
actor.orgId
|
actor.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Read,
|
||||||
|
OrgPermissionSubjects.AppConnections
|
||||||
|
);
|
||||||
|
|
||||||
const appConnections = await appConnectionDAL.find(
|
const appConnections = await appConnectionDAL.find(
|
||||||
app
|
app
|
||||||
@ -78,24 +70,11 @@ export const appConnectionServiceFactory = ({
|
|||||||
return Promise.all(
|
return Promise.all(
|
||||||
appConnections
|
appConnections
|
||||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||||
.map(async ({ encryptedCredentials, ...connection }) => {
|
.map((appConnection) => decryptAppConnection(appConnection, kmsService))
|
||||||
const credentials = await decryptAppConnectionCredentials({
|
|
||||||
encryptedCredentials,
|
|
||||||
kmsService,
|
|
||||||
orgId: connection.orgId
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...connection,
|
|
||||||
credentials
|
|
||||||
} as TAppConnection;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
|
const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
|
||||||
await checkAppServicesAvailability(actor.orgId);
|
|
||||||
|
|
||||||
const appConnection = await appConnectionDAL.findById(connectionId);
|
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||||
|
|
||||||
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
||||||
@ -108,24 +87,18 @@ export const appConnectionServiceFactory = ({
|
|||||||
appConnection.orgId
|
appConnection.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Read,
|
||||||
|
OrgPermissionSubjects.AppConnections
|
||||||
|
);
|
||||||
|
|
||||||
if (appConnection.app !== app)
|
if (appConnection.app !== app)
|
||||||
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
|
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
|
||||||
|
|
||||||
return {
|
return decryptAppConnection(appConnection, kmsService);
|
||||||
...appConnection,
|
|
||||||
credentials: await decryptAppConnectionCredentials({
|
|
||||||
encryptedCredentials: appConnection.encryptedCredentials,
|
|
||||||
orgId: appConnection.orgId,
|
|
||||||
kmsService
|
|
||||||
})
|
|
||||||
} as TAppConnection;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => {
|
const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => {
|
||||||
await checkAppServicesAvailability(actor.orgId);
|
|
||||||
|
|
||||||
const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId });
|
const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId });
|
||||||
|
|
||||||
if (!appConnection)
|
if (!appConnection)
|
||||||
@ -139,27 +112,21 @@ export const appConnectionServiceFactory = ({
|
|||||||
appConnection.orgId
|
appConnection.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Read,
|
||||||
|
OrgPermissionSubjects.AppConnections
|
||||||
|
);
|
||||||
|
|
||||||
if (appConnection.app !== app)
|
if (appConnection.app !== app)
|
||||||
throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` });
|
throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` });
|
||||||
|
|
||||||
return {
|
return decryptAppConnection(appConnection, kmsService);
|
||||||
...appConnection,
|
|
||||||
credentials: await decryptAppConnectionCredentials({
|
|
||||||
encryptedCredentials: appConnection.encryptedCredentials,
|
|
||||||
orgId: appConnection.orgId,
|
|
||||||
kmsService
|
|
||||||
})
|
|
||||||
} as TAppConnection;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createAppConnection = async (
|
const createAppConnection = async (
|
||||||
{ method, app, credentials, ...params }: TCreateAppConnectionDTO,
|
{ method, app, credentials, ...params }: TCreateAppConnectionDTO,
|
||||||
actor: OrgServiceActor
|
actor: OrgServiceActor
|
||||||
) => {
|
) => {
|
||||||
await checkAppServicesAvailability(actor.orgId);
|
|
||||||
|
|
||||||
const { permission } = await permissionService.getOrgPermission(
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
actor.type,
|
actor.type,
|
||||||
actor.id,
|
actor.id,
|
||||||
@ -168,7 +135,10 @@ export const appConnectionServiceFactory = ({
|
|||||||
actor.orgId
|
actor.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Create,
|
||||||
|
OrgPermissionSubjects.AppConnections
|
||||||
|
);
|
||||||
|
|
||||||
const appConnection = await appConnectionDAL.transaction(async (tx) => {
|
const appConnection = await appConnectionDAL.transaction(async (tx) => {
|
||||||
const isConflictingName = Boolean(
|
const isConflictingName = Boolean(
|
||||||
@ -216,15 +186,13 @@ export const appConnectionServiceFactory = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return appConnection;
|
return appConnection as TAppConnection;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAppConnection = async (
|
const updateAppConnection = async (
|
||||||
{ connectionId, credentials, ...params }: TUpdateAppConnectionDTO,
|
{ connectionId, credentials, ...params }: TUpdateAppConnectionDTO,
|
||||||
actor: OrgServiceActor
|
actor: OrgServiceActor
|
||||||
) => {
|
) => {
|
||||||
await checkAppServicesAvailability(actor.orgId);
|
|
||||||
|
|
||||||
const appConnection = await appConnectionDAL.findById(connectionId);
|
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||||
|
|
||||||
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
||||||
@ -237,7 +205,10 @@ export const appConnectionServiceFactory = ({
|
|||||||
appConnection.orgId
|
appConnection.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Edit,
|
||||||
|
OrgPermissionSubjects.AppConnections
|
||||||
|
);
|
||||||
|
|
||||||
const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => {
|
const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => {
|
||||||
if (params.name && appConnection.name !== params.name) {
|
if (params.name && appConnection.name !== params.name) {
|
||||||
@ -304,19 +275,10 @@ export const appConnectionServiceFactory = ({
|
|||||||
return updatedConnection;
|
return updatedConnection;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return decryptAppConnection(updatedAppConnection, kmsService);
|
||||||
...updatedAppConnection,
|
|
||||||
credentials: await decryptAppConnectionCredentials({
|
|
||||||
encryptedCredentials: updatedAppConnection.encryptedCredentials,
|
|
||||||
orgId: updatedAppConnection.orgId,
|
|
||||||
kmsService
|
|
||||||
})
|
|
||||||
} as TAppConnection;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
|
const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
|
||||||
await checkAppServicesAvailability(actor.orgId);
|
|
||||||
|
|
||||||
const appConnection = await appConnectionDAL.findById(connectionId);
|
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||||
|
|
||||||
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
||||||
@ -329,23 +291,85 @@ export const appConnectionServiceFactory = ({
|
|||||||
appConnection.orgId
|
appConnection.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Delete,
|
||||||
|
OrgPermissionSubjects.AppConnections
|
||||||
|
);
|
||||||
|
|
||||||
if (appConnection.app !== app)
|
if (appConnection.app !== app)
|
||||||
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
|
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
|
||||||
|
|
||||||
// TODO: specify delete error message if due to existing dependencies
|
// TODO (scott): add option to delete all dependencies
|
||||||
|
|
||||||
|
try {
|
||||||
const deletedAppConnection = await appConnectionDAL.deleteById(connectionId);
|
const deletedAppConnection = await appConnectionDAL.deleteById(connectionId);
|
||||||
|
|
||||||
return {
|
return await decryptAppConnection(deletedAppConnection, kmsService);
|
||||||
...deletedAppConnection,
|
} catch (err) {
|
||||||
credentials: await decryptAppConnectionCredentials({
|
if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23503") {
|
||||||
encryptedCredentials: deletedAppConnection.encryptedCredentials,
|
throw new BadRequestError({
|
||||||
orgId: deletedAppConnection.orgId,
|
message:
|
||||||
kmsService
|
"Cannot delete App Connection with existing connections. Remove all existing connections and try again."
|
||||||
})
|
});
|
||||||
} as TAppConnection;
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectAppConnectionById = async <T extends TAppConnection>(
|
||||||
|
app: AppConnection,
|
||||||
|
connectionId: string,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||||
|
|
||||||
|
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
|
||||||
|
|
||||||
|
const { permission: orgPermission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
appConnection.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(orgPermission).throwUnlessCan(
|
||||||
|
OrgPermissionAppConnectionActions.Connect,
|
||||||
|
subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appConnection.app !== app)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `${
|
||||||
|
APP_CONNECTION_NAME_MAP[appConnection.app as AppConnection]
|
||||||
|
} Connection with ID ${connectionId} cannot be used to connect to ${APP_CONNECTION_NAME_MAP[app]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const connection = await decryptAppConnection(appConnection, kmsService);
|
||||||
|
|
||||||
|
return connection as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor) => {
|
||||||
|
const { permission: orgPermission } = await permissionService.getOrgPermission(
|
||||||
|
actor.type,
|
||||||
|
actor.id,
|
||||||
|
actor.orgId,
|
||||||
|
actor.authMethod,
|
||||||
|
actor.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
const appConnections = await appConnectionDAL.find({ app, orgId: actor.orgId });
|
||||||
|
|
||||||
|
const availableConnections = appConnections.filter((connection) =>
|
||||||
|
orgPermission.can(
|
||||||
|
OrgPermissionAppConnectionActions.Connect,
|
||||||
|
subject(OrgPermissionSubjects.AppConnections, { connectionId: connection.id })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return availableConnections as Omit<TAppConnection, "credentials">[];
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -355,6 +379,9 @@ export const appConnectionServiceFactory = ({
|
|||||||
findAppConnectionByName,
|
findAppConnectionByName,
|
||||||
createAppConnection,
|
createAppConnection,
|
||||||
updateAppConnection,
|
updateAppConnection,
|
||||||
deleteAppConnection
|
deleteAppConnection,
|
||||||
|
connectAppConnectionById,
|
||||||
|
listAvailableAppConnectionsForUser,
|
||||||
|
github: githubConnectionService(connectAppConnectionById)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -4,7 +4,7 @@ import { randomUUID } from "crypto";
|
|||||||
|
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
|
||||||
import { AwsConnectionMethod } from "./aws-connection-enums";
|
import { AwsConnectionMethod } from "./aws-connection-enums";
|
||||||
import { TAwsConnectionConfig } from "./aws-connection-types";
|
import { TAwsConnectionConfig } from "./aws-connection-types";
|
||||||
@ -20,7 +20,7 @@ export const getAwsAppConnectionListItem = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = "us-east-1") => {
|
export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = AWSRegion.US_EAST_1) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
|
||||||
let accessKeyId: string;
|
let accessKeyId: string;
|
||||||
|
@ -38,11 +38,11 @@ export const AwsConnectionSchema = z.intersection(
|
|||||||
export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
|
export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
|
||||||
BaseAwsConnectionSchema.extend({
|
BaseAwsConnectionSchema.extend({
|
||||||
method: z.literal(AwsConnectionMethod.AssumeRole),
|
method: z.literal(AwsConnectionMethod.AssumeRole),
|
||||||
credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true })
|
credentials: AwsConnectionAssumeRoleCredentialsSchema.pick({})
|
||||||
}),
|
}),
|
||||||
BaseAwsConnectionSchema.extend({
|
BaseAwsConnectionSchema.extend({
|
||||||
method: z.literal(AwsConnectionMethod.AccessKey),
|
method: z.literal(AwsConnectionMethod.AccessKey),
|
||||||
credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true })
|
credentials: AwsConnectionAccessTokenCredentialsSchema.pick({ accessKeyId: true })
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ export const UpdateAwsConnectionSchema = z
|
|||||||
export const AwsConnectionListItemSchema = z.object({
|
export const AwsConnectionListItemSchema = z.object({
|
||||||
name: z.literal("AWS"),
|
name: z.literal("AWS"),
|
||||||
app: z.literal(AppConnection.AWS),
|
app: z.literal(AppConnection.AWS),
|
||||||
// the below is preferable but currently breaks mintlify
|
// the below is preferable but currently breaks with our zod to json schema parser
|
||||||
// methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]),
|
// methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]),
|
||||||
methods: z.nativeEnum(AwsConnectionMethod).array(),
|
methods: z.nativeEnum(AwsConnectionMethod).array(),
|
||||||
accessKeyId: z.string().optional()
|
accessKeyId: z.string().optional()
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { createAppAuth } from "@octokit/auth-app";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
@ -8,7 +10,7 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
|
|||||||
|
|
||||||
import { AppConnection } from "../app-connection-enums";
|
import { AppConnection } from "../app-connection-enums";
|
||||||
import { GitHubConnectionMethod } from "./github-connection-enums";
|
import { GitHubConnectionMethod } from "./github-connection-enums";
|
||||||
import { TGitHubConnectionConfig } from "./github-connection-types";
|
import { TGitHubConnection, TGitHubConnectionConfig } from "./github-connection-types";
|
||||||
|
|
||||||
export const getGitHubConnectionListItem = () => {
|
export const getGitHubConnectionListItem = () => {
|
||||||
const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig();
|
const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig();
|
||||||
@ -22,10 +24,131 @@ export const getGitHubConnectionListItem = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getGitHubClient = (appConnection: TGitHubConnection) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
const { method, credentials } = appConnection;
|
||||||
|
|
||||||
|
let client: Octokit;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case GitHubConnectionMethod.App:
|
||||||
|
if (!appCfg.INF_APP_CONNECTION_GITHUB_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY) {
|
||||||
|
throw new InternalServerError({
|
||||||
|
message: `GitHub ${getAppConnectionMethodName(method).replace(
|
||||||
|
"GitHub",
|
||||||
|
""
|
||||||
|
)} environment variables have not been configured`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new Octokit({
|
||||||
|
authStrategy: createAppAuth,
|
||||||
|
auth: {
|
||||||
|
appId: appCfg.INF_APP_CONNECTION_GITHUB_APP_ID,
|
||||||
|
privateKey: appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY,
|
||||||
|
installationId: credentials.installationId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case GitHubConnectionMethod.OAuth:
|
||||||
|
client = new Octokit({
|
||||||
|
auth: credentials.accessToken
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InternalServerError({
|
||||||
|
message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubOrganization = {
|
||||||
|
login: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubRepository = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
owner: GitHubOrganization;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGitHubRepositories = async (appConnection: TGitHubConnection) => {
|
||||||
|
const client = getGitHubClient(appConnection);
|
||||||
|
|
||||||
|
let repositories: GitHubRepository[];
|
||||||
|
|
||||||
|
switch (appConnection.method) {
|
||||||
|
case GitHubConnectionMethod.App:
|
||||||
|
repositories = await client.paginate("GET /installation/repositories");
|
||||||
|
break;
|
||||||
|
case GitHubConnectionMethod.OAuth:
|
||||||
|
default:
|
||||||
|
repositories = (await client.paginate("GET /user/repos")).filter((repo) => repo.permissions?.admin);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return repositories;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGitHubOrganizations = async (appConnection: TGitHubConnection) => {
|
||||||
|
const client = getGitHubClient(appConnection);
|
||||||
|
|
||||||
|
let organizations: GitHubOrganization[];
|
||||||
|
|
||||||
|
switch (appConnection.method) {
|
||||||
|
case GitHubConnectionMethod.App: {
|
||||||
|
const installationRepositories = await client.paginate("GET /installation/repositories");
|
||||||
|
|
||||||
|
const organizationMap: Record<string, GitHubOrganization> = {};
|
||||||
|
|
||||||
|
installationRepositories.forEach((repo) => {
|
||||||
|
if (repo.owner.type === "Organization") {
|
||||||
|
organizationMap[repo.owner.id] = repo.owner;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
organizations = Object.values(organizationMap);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubConnectionMethod.OAuth:
|
||||||
|
default:
|
||||||
|
organizations = await client.paginate("GET /user/orgs");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return organizations;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGitHubEnvironments = async (appConnection: TGitHubConnection, owner: string, repo: string) => {
|
||||||
|
const client = getGitHubClient(appConnection);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const environments = await client.paginate("GET /repos/{owner}/{repo}/environments", {
|
||||||
|
owner,
|
||||||
|
repo
|
||||||
|
});
|
||||||
|
|
||||||
|
return environments;
|
||||||
|
} catch (e) {
|
||||||
|
// repo doesn't have envs
|
||||||
|
if ((e as { status: number }).status === 404) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
type TokenRespData = {
|
type TokenRespData = {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => {
|
export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => {
|
||||||
@ -53,7 +176,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
|||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
if (!clientId || !clientSecret) {
|
||||||
throw new InternalServerError({
|
throw new InternalServerError({
|
||||||
message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured`
|
message: `GitHub ${getAppConnectionMethodName(method).replace(
|
||||||
|
"GitHub",
|
||||||
|
""
|
||||||
|
)} environment variables have not been configured`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +191,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
|||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
code: credentials.code,
|
code: credentials.code,
|
||||||
redirect_uri: `${SITE_URL}/app-connections/github/oauth/callback`
|
redirect_uri: `${SITE_URL}/organization/app-connections/github/oauth/callback`
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@ -90,6 +216,8 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
|||||||
id: number;
|
id: number;
|
||||||
account: {
|
account: {
|
||||||
login: string;
|
login: string;
|
||||||
|
type: string;
|
||||||
|
id: number;
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
}>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, {
|
}>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, {
|
||||||
@ -111,10 +239,13 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tokenResp.data.access_token) {
|
||||||
|
throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` });
|
||||||
|
}
|
||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case GitHubConnectionMethod.App:
|
case GitHubConnectionMethod.App:
|
||||||
return {
|
return {
|
||||||
// access token not needed for GitHub App
|
|
||||||
installationId: credentials.installationId
|
installationId: credentials.installationId
|
||||||
};
|
};
|
||||||
case GitHubConnectionMethod.OAuth:
|
case GitHubConnectionMethod.OAuth:
|
||||||
|
@ -57,7 +57,7 @@ export const UpdateGitHubConnectionSchema = z
|
|||||||
|
|
||||||
const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) });
|
const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) });
|
||||||
|
|
||||||
export const GitHubAppConnectionSchema = z.intersection(
|
export const GitHubConnectionSchema = z.intersection(
|
||||||
BaseGitHubConnectionSchema,
|
BaseGitHubConnectionSchema,
|
||||||
z.discriminatedUnion("method", [
|
z.discriminatedUnion("method", [
|
||||||
z.object({
|
z.object({
|
||||||
@ -74,19 +74,19 @@ export const GitHubAppConnectionSchema = z.intersection(
|
|||||||
export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
|
export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
|
||||||
BaseGitHubConnectionSchema.extend({
|
BaseGitHubConnectionSchema.extend({
|
||||||
method: z.literal(GitHubConnectionMethod.App),
|
method: z.literal(GitHubConnectionMethod.App),
|
||||||
credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true })
|
credentials: GitHubConnectionAppOutputCredentialsSchema.pick({})
|
||||||
}),
|
}),
|
||||||
BaseGitHubConnectionSchema.extend({
|
BaseGitHubConnectionSchema.extend({
|
||||||
method: z.literal(GitHubConnectionMethod.OAuth),
|
method: z.literal(GitHubConnectionMethod.OAuth),
|
||||||
credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true })
|
credentials: GitHubConnectionOAuthOutputCredentialsSchema.pick({})
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const GitHubConnectionListItemSchema = z.object({
|
export const GitHubConnectionListItemSchema = z.object({
|
||||||
name: z.literal("GitHub"),
|
name: z.literal("GitHub"),
|
||||||
app: z.literal(AppConnection.GitHub),
|
app: z.literal(AppConnection.GitHub),
|
||||||
// the below is preferable but currently breaks mintlify
|
// the below is preferable but currently breaks with our zod to json schema parser
|
||||||
// methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]),
|
// methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]),
|
||||||
methods: z.nativeEnum(GitHubConnectionMethod).array(),
|
methods: z.nativeEnum(GitHubConnectionMethod).array(),
|
||||||
oauthClientId: z.string().optional(),
|
oauthClientId: z.string().optional(),
|
||||||
appClientSlug: z.string().optional()
|
appClientSlug: z.string().optional()
|
||||||
|
@ -0,0 +1,55 @@
|
|||||||
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import {
|
||||||
|
getGitHubEnvironments,
|
||||||
|
getGitHubOrganizations,
|
||||||
|
getGitHubRepositories
|
||||||
|
} from "@app/services/app-connection/github/github-connection-fns";
|
||||||
|
import { TGitHubConnection } from "@app/services/app-connection/github/github-connection-types";
|
||||||
|
|
||||||
|
type TGetAppConnectionFunc = (
|
||||||
|
app: AppConnection,
|
||||||
|
connectionId: string,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => Promise<TGitHubConnection>;
|
||||||
|
|
||||||
|
type TListGitHubEnvironmentsDTO = {
|
||||||
|
connectionId: string;
|
||||||
|
repo: string;
|
||||||
|
owner: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
|
||||||
|
const listRepositories = async (connectionId: string, actor: OrgServiceActor) => {
|
||||||
|
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
|
||||||
|
|
||||||
|
const repositories = await getGitHubRepositories(appConnection);
|
||||||
|
|
||||||
|
return repositories;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => {
|
||||||
|
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
|
||||||
|
|
||||||
|
const organizations = await getGitHubOrganizations(appConnection);
|
||||||
|
|
||||||
|
return organizations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listEnvironments = async (
|
||||||
|
{ connectionId, repo, owner }: TListGitHubEnvironmentsDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
|
||||||
|
|
||||||
|
const environments = await getGitHubEnvironments(appConnection, owner, repo);
|
||||||
|
|
||||||
|
return environments;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
listRepositories,
|
||||||
|
listOrganizations,
|
||||||
|
listEnvironments
|
||||||
|
};
|
||||||
|
};
|
@ -5,11 +5,11 @@ import { DiscriminativePick } from "@app/lib/types";
|
|||||||
import { AppConnection } from "../app-connection-enums";
|
import { AppConnection } from "../app-connection-enums";
|
||||||
import {
|
import {
|
||||||
CreateGitHubConnectionSchema,
|
CreateGitHubConnectionSchema,
|
||||||
GitHubAppConnectionSchema,
|
GitHubConnectionSchema,
|
||||||
ValidateGitHubConnectionCredentialsSchema
|
ValidateGitHubConnectionCredentialsSchema
|
||||||
} from "./github-connection-schemas";
|
} from "./github-connection-schemas";
|
||||||
|
|
||||||
export type TGitHubConnection = z.infer<typeof GitHubAppConnectionSchema>;
|
export type TGitHubConnection = z.infer<typeof GitHubConnectionSchema>;
|
||||||
|
|
||||||
export type TGitHubConnectionInput = z.infer<typeof CreateGitHubConnectionSchema> & {
|
export type TGitHubConnectionInput = z.infer<typeof CreateGitHubConnectionSchema> & {
|
||||||
app: AppConnection.GitHub;
|
app: AppConnection.GitHub;
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
export const AWS_PARAMETER_STORE_SYNC_LIST_OPTION: TSecretSyncListItem = {
|
||||||
|
name: "AWS Parameter Store",
|
||||||
|
destination: SecretSync.AWSParameterStore,
|
||||||
|
connection: AppConnection.AWS,
|
||||||
|
canImportSecrets: true
|
||||||
|
};
|
@ -0,0 +1,207 @@
|
|||||||
|
import AWS, { AWSError } from "aws-sdk";
|
||||||
|
|
||||||
|
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
||||||
|
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||||
|
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types";
|
||||||
|
|
||||||
|
type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
|
||||||
|
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const BATCH_SIZE = 10;
|
||||||
|
|
||||||
|
const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => {
|
||||||
|
const { destinationConfig, connection } = secretSync;
|
||||||
|
|
||||||
|
const config = await getAwsConnectionConfig(connection, destinationConfig.region);
|
||||||
|
|
||||||
|
const ssm = new AWS.SSM({
|
||||||
|
apiVersion: "2014-11-06",
|
||||||
|
region: destinationConfig.region
|
||||||
|
});
|
||||||
|
|
||||||
|
ssm.config.update(config);
|
||||||
|
|
||||||
|
return ssm;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = async () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => {
|
||||||
|
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
|
||||||
|
let hasNext = true;
|
||||||
|
let nextToken: string | undefined;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (hasNext) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const parameters = await ssm
|
||||||
|
.getParametersByPath({
|
||||||
|
Path: path,
|
||||||
|
Recursive: false,
|
||||||
|
WithDecryption: true,
|
||||||
|
MaxResults: BATCH_SIZE,
|
||||||
|
NextToken: nextToken
|
||||||
|
})
|
||||||
|
.promise();
|
||||||
|
|
||||||
|
attempt = 0;
|
||||||
|
|
||||||
|
if (parameters.Parameters) {
|
||||||
|
parameters.Parameters.forEach((parameter) => {
|
||||||
|
if (parameter.Name) {
|
||||||
|
// no leading slash if path is '/'
|
||||||
|
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||||
|
awsParameterStoreSecretsRecord[secKey] = parameter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNext = Boolean(parameters.NextToken);
|
||||||
|
nextToken = parameters.NextToken;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
|
||||||
|
attempt += 1;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await sleep();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return awsParameterStoreSecretsRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
const putParameter = async (
|
||||||
|
ssm: AWS.SSM,
|
||||||
|
params: AWS.SSM.PutParameterRequest,
|
||||||
|
attempt = 0
|
||||||
|
): Promise<AWS.SSM.PutParameterResult> => {
|
||||||
|
try {
|
||||||
|
return await ssm.putParameter(params).promise();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
|
||||||
|
await sleep();
|
||||||
|
|
||||||
|
// retry
|
||||||
|
return putParameter(ssm, params, attempt + 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteParametersBatch = async (
|
||||||
|
ssm: AWS.SSM,
|
||||||
|
parameters: AWS.SSM.Parameter[],
|
||||||
|
attempt = 0
|
||||||
|
): Promise<AWS.SSM.DeleteParameterResult[]> => {
|
||||||
|
const results: AWS.SSM.DeleteParameterResult[] = [];
|
||||||
|
let remainingParams = [...parameters];
|
||||||
|
|
||||||
|
while (remainingParams.length > 0) {
|
||||||
|
const batch = remainingParams.slice(0, BATCH_SIZE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const result = await ssm.deleteParameters({ Names: batch.map((param) => param.Name!) }).promise();
|
||||||
|
results.push(result);
|
||||||
|
remainingParams = remainingParams.slice(BATCH_SIZE);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await sleep();
|
||||||
|
|
||||||
|
// Retry the current batch
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
return [...results, ...(await deleteParametersBatch(ssm, remainingParams, attempt + 1))];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AwsParameterStoreSyncFns = {
|
||||||
|
syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
const ssm = await getSSM(secretSync);
|
||||||
|
|
||||||
|
// TODO(scott): KMS Key ID, Tags
|
||||||
|
|
||||||
|
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
||||||
|
|
||||||
|
for await (const entry of Object.entries(secretMap)) {
|
||||||
|
const [key, { value }] = entry;
|
||||||
|
|
||||||
|
// skip empty values (not allowed by AWS) or secrets that haven't changed
|
||||||
|
if (!value || (key in awsParameterStoreSecretsRecord && awsParameterStoreSecretsRecord[key].Value === value)) {
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putParameter(ssm, {
|
||||||
|
Name: `${destinationConfig.path}${key}`,
|
||||||
|
Type: "SecureString",
|
||||||
|
Value: value,
|
||||||
|
Overwrite: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new SecretSyncError({
|
||||||
|
error,
|
||||||
|
secretKey: key
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parametersToDelete: AWS.SSM.Parameter[] = [];
|
||||||
|
|
||||||
|
for (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
|
||||||
|
const [key, parameter] = entry;
|
||||||
|
|
||||||
|
if (!(key in secretMap) || !secretMap[key].value) {
|
||||||
|
parametersToDelete.push(parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteParametersBatch(ssm, parametersToDelete);
|
||||||
|
},
|
||||||
|
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
const ssm = await getSSM(secretSync);
|
||||||
|
|
||||||
|
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
const ssm = await getSSM(secretSync);
|
||||||
|
|
||||||
|
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
||||||
|
|
||||||
|
const parametersToDelete: AWS.SSM.Parameter[] = [];
|
||||||
|
|
||||||
|
for (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
|
||||||
|
const [key, param] = entry;
|
||||||
|
|
||||||
|
if (key in secretMap) {
|
||||||
|
parametersToDelete.push(param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteParametersBatch(ssm, parametersToDelete);
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,45 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { SecretSyncs } from "@app/lib/api-docs";
|
||||||
|
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import {
|
||||||
|
BaseSecretSyncSchema,
|
||||||
|
GenericCreateSecretSyncFieldsSchema,
|
||||||
|
GenericUpdateSecretSyncFieldsSchema
|
||||||
|
} from "@app/services/secret-sync/secret-sync-schemas";
|
||||||
|
|
||||||
|
const AwsParameterStoreSyncDestinationConfigSchema = z.object({
|
||||||
|
region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.REGION),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Parameter Store Path required")
|
||||||
|
.max(2048, "Cannot exceed 2048 characters")
|
||||||
|
.regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format')
|
||||||
|
.describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.PATH)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(SecretSync.AWSParameterStore).extend({
|
||||||
|
destination: z.literal(SecretSync.AWSParameterStore),
|
||||||
|
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema(
|
||||||
|
SecretSync.AWSParameterStore
|
||||||
|
).extend({
|
||||||
|
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSchema(
|
||||||
|
SecretSync.AWSParameterStore
|
||||||
|
).extend({
|
||||||
|
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AwsParameterStoreSyncListItemSchema = z.object({
|
||||||
|
name: z.literal("AWS Parameter Store"),
|
||||||
|
connection: z.literal(AppConnection.AWS),
|
||||||
|
destination: z.literal(SecretSync.AWSParameterStore),
|
||||||
|
canImportSecrets: z.literal(true)
|
||||||
|
});
|
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TAwsConnection } from "@app/services/app-connection/aws";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AwsParameterStoreSyncListItemSchema,
|
||||||
|
AwsParameterStoreSyncSchema,
|
||||||
|
CreateAwsParameterStoreSyncSchema
|
||||||
|
} from "./aws-parameter-store-sync-schemas";
|
||||||
|
|
||||||
|
export type TAwsParameterStoreSync = z.infer<typeof AwsParameterStoreSyncSchema>;
|
||||||
|
|
||||||
|
export type TAwsParameterStoreSyncInput = z.infer<typeof CreateAwsParameterStoreSyncSchema>;
|
||||||
|
|
||||||
|
export type TAwsParameterStoreSyncListItem = z.infer<typeof AwsParameterStoreSyncListItemSchema>;
|
||||||
|
|
||||||
|
export type TAwsParameterStoreSyncWithCredentials = TAwsParameterStoreSync & {
|
||||||
|
connection: TAwsConnection;
|
||||||
|
};
|
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./aws-parameter-store-sync-constants";
|
||||||
|
export * from "./aws-parameter-store-sync-fns";
|
||||||
|
export * from "./aws-parameter-store-sync-schemas";
|
||||||
|
export * from "./aws-parameter-store-sync-types";
|
@ -0,0 +1,10 @@
|
|||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
export const GITHUB_SYNC_LIST_OPTION: TSecretSyncListItem = {
|
||||||
|
name: "GitHub",
|
||||||
|
destination: SecretSync.GitHub,
|
||||||
|
connection: AppConnection.GitHub,
|
||||||
|
canImportSecrets: false
|
||||||
|
};
|
11
backend/src/services/secret-sync/github/github-sync-enums.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export enum GitHubSyncScope {
|
||||||
|
Repository = "repository",
|
||||||
|
Organization = "organization",
|
||||||
|
RepositoryEnvironment = "repository-environment"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GitHubSyncVisibility {
|
||||||
|
All = "all",
|
||||||
|
Private = "private",
|
||||||
|
Selected = "selected"
|
||||||
|
}
|
242
backend/src/services/secret-sync/github/github-sync-fns.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
|
import { getGitHubClient } from "@app/services/app-connection/github";
|
||||||
|
import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums";
|
||||||
|
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||||
|
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||||
|
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithCredentials } from "./github-sync-types";
|
||||||
|
|
||||||
|
// TODO: rate limit handling
|
||||||
|
|
||||||
|
const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => {
|
||||||
|
let encryptedSecrets: TGitHubSecret[];
|
||||||
|
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
switch (destinationConfig.scope) {
|
||||||
|
case GitHubSyncScope.Organization: {
|
||||||
|
encryptedSecrets = await client.paginate("GET /orgs/{org}/actions/secrets", {
|
||||||
|
org: destinationConfig.org
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.Repository: {
|
||||||
|
encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/actions/secrets", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.RepositoryEnvironment:
|
||||||
|
default: {
|
||||||
|
encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo,
|
||||||
|
environment_name: destinationConfig.env
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => {
|
||||||
|
let publicKey: TGitHubPublicKey;
|
||||||
|
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
switch (destinationConfig.scope) {
|
||||||
|
case GitHubSyncScope.Organization: {
|
||||||
|
publicKey = (
|
||||||
|
await client.request("GET /orgs/{org}/actions/secrets/public-key", {
|
||||||
|
org: destinationConfig.org
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.Repository: {
|
||||||
|
publicKey = (
|
||||||
|
await client.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.RepositoryEnvironment:
|
||||||
|
default: {
|
||||||
|
publicKey = (
|
||||||
|
await client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo,
|
||||||
|
environment_name: destinationConfig.env
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSecret = async (
|
||||||
|
client: Octokit,
|
||||||
|
secretSync: TGitHubSyncWithCredentials,
|
||||||
|
encryptedSecret: TGitHubSecret
|
||||||
|
) => {
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
switch (destinationConfig.scope) {
|
||||||
|
case GitHubSyncScope.Organization: {
|
||||||
|
await client.request(`DELETE /orgs/{org}/actions/secrets/{secret_name}`, {
|
||||||
|
org: destinationConfig.org,
|
||||||
|
secret_name: encryptedSecret.name
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.Repository: {
|
||||||
|
await client.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo,
|
||||||
|
secret_name: encryptedSecret.name
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.RepositoryEnvironment:
|
||||||
|
default: {
|
||||||
|
await client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo,
|
||||||
|
environment_name: destinationConfig.env,
|
||||||
|
secret_name: encryptedSecret.name
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: TGitHubSecretPayload) => {
|
||||||
|
const { destinationConfig } = secretSync;
|
||||||
|
|
||||||
|
switch (destinationConfig.scope) {
|
||||||
|
case GitHubSyncScope.Organization: {
|
||||||
|
const { visibility, selectedRepositoryIds } = destinationConfig;
|
||||||
|
|
||||||
|
await client.request(`PUT /orgs/{org}/actions/secrets/{secret_name}`, {
|
||||||
|
org: destinationConfig.org,
|
||||||
|
...payload,
|
||||||
|
visibility,
|
||||||
|
...(visibility === GitHubSyncVisibility.Selected && {
|
||||||
|
selected_repository_ids: selectedRepositoryIds
|
||||||
|
})
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.Repository: {
|
||||||
|
await client.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GitHubSyncScope.RepositoryEnvironment:
|
||||||
|
default: {
|
||||||
|
await client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
|
||||||
|
owner: destinationConfig.owner,
|
||||||
|
repo: destinationConfig.repo,
|
||||||
|
environment_name: destinationConfig.env,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GithubSyncFns = {
|
||||||
|
syncSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => {
|
||||||
|
switch (secretSync.destinationConfig.scope) {
|
||||||
|
case GitHubSyncScope.Organization:
|
||||||
|
if (Object.values(secretMap).length > 1000) {
|
||||||
|
throw new SecretSyncError({
|
||||||
|
message: "GitHub does not support storing more than 1,000 secrets at the organization level.",
|
||||||
|
shouldRetry: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case GitHubSyncScope.Repository:
|
||||||
|
case GitHubSyncScope.RepositoryEnvironment:
|
||||||
|
if (Object.values(secretMap).length > 100) {
|
||||||
|
throw new SecretSyncError({
|
||||||
|
message: "GitHub does not support storing more than 100 secrets at the repository level.",
|
||||||
|
shouldRetry: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported GitHub Sync scope ${
|
||||||
|
(secretSync.destinationConfig as TGitHubSyncWithCredentials["destinationConfig"]).scope
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getGitHubClient(secretSync.connection);
|
||||||
|
|
||||||
|
const encryptedSecrets = await getEncryptedSecrets(client, secretSync);
|
||||||
|
|
||||||
|
const publicKey = await getPublicKey(client, secretSync);
|
||||||
|
|
||||||
|
for await (const encryptedSecret of encryptedSecrets) {
|
||||||
|
if (!(encryptedSecret.name in secretMap)) {
|
||||||
|
await deleteSecret(client, secretSync, encryptedSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sodium.ready.then(async () => {
|
||||||
|
for await (const key of Object.keys(secretMap)) {
|
||||||
|
// convert secret & base64 key to Uint8Array.
|
||||||
|
const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL);
|
||||||
|
const binarySecretValue = sodium.from_string(secretMap[key].value);
|
||||||
|
|
||||||
|
// encrypt secret using libsodium
|
||||||
|
const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey);
|
||||||
|
|
||||||
|
// convert encrypted Uint8Array to base64
|
||||||
|
const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putSecret(client, secretSync, {
|
||||||
|
secret_name: key,
|
||||||
|
encrypted_value: encryptedSecretValue,
|
||||||
|
key_id: publicKey.key_id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new SecretSyncError({
|
||||||
|
error,
|
||||||
|
secretKey: key
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getSecrets: async (secretSync: TGitHubSyncWithCredentials) => {
|
||||||
|
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`);
|
||||||
|
},
|
||||||
|
removeSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => {
|
||||||
|
const client = getGitHubClient(secretSync.connection);
|
||||||
|
|
||||||
|
const encryptedSecrets = await getEncryptedSecrets(client, secretSync);
|
||||||
|
|
||||||
|
for await (const encryptedSecret of encryptedSecrets) {
|
||||||
|
if (encryptedSecret.name in secretMap) {
|
||||||
|
await deleteSecret(client, secretSync, encryptedSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,82 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { SecretSyncs } from "@app/lib/api-docs";
|
||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import {
|
||||||
|
BaseSecretSyncSchema,
|
||||||
|
GenericCreateSecretSyncFieldsSchema,
|
||||||
|
GenericUpdateSecretSyncFieldsSchema
|
||||||
|
} from "@app/services/secret-sync/secret-sync-schemas";
|
||||||
|
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
const GitHubSyncDestinationConfigSchema = z
|
||||||
|
.discriminatedUnion("scope", [
|
||||||
|
z.object({
|
||||||
|
scope: z.literal(GitHubSyncScope.Organization),
|
||||||
|
org: z.string().min(1, "Organization name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ORG),
|
||||||
|
visibility: z.nativeEnum(GitHubSyncVisibility),
|
||||||
|
selectedRepositoryIds: z.number().array().optional()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
scope: z.literal(GitHubSyncScope.Repository),
|
||||||
|
owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER),
|
||||||
|
repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO)
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
|
||||||
|
owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER),
|
||||||
|
repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO),
|
||||||
|
env: z.string().min(1, "Environment name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ENV)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
.superRefine((options, ctx) => {
|
||||||
|
if (options.scope === GitHubSyncScope.Organization) {
|
||||||
|
if (options.visibility === GitHubSyncVisibility.Selected) {
|
||||||
|
if (!options.selectedRepositoryIds?.length)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Select at least 1 repository",
|
||||||
|
path: ["selectedRepositoryIds"]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.selectedRepositoryIds?.length) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Selected repositories is only supported for visibility "Selected"`,
|
||||||
|
path: ["selectedRepositoryIds"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const GitHubSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
|
||||||
|
|
||||||
|
export const GitHubSyncSchema = BaseSecretSyncSchema(SecretSync.GitHub, GitHubSyncOptionsConfig).extend({
|
||||||
|
destination: z.literal(SecretSync.GitHub),
|
||||||
|
destinationConfig: GitHubSyncDestinationConfigSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateGitHubSyncSchema = GenericCreateSecretSyncFieldsSchema(
|
||||||
|
SecretSync.GitHub,
|
||||||
|
GitHubSyncOptionsConfig
|
||||||
|
).extend({
|
||||||
|
destinationConfig: GitHubSyncDestinationConfigSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateGitHubSyncSchema = GenericUpdateSecretSyncFieldsSchema(
|
||||||
|
SecretSync.GitHub,
|
||||||
|
GitHubSyncOptionsConfig
|
||||||
|
).extend({
|
||||||
|
destinationConfig: GitHubSyncDestinationConfigSchema.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GitHubSyncListItemSchema = z.object({
|
||||||
|
name: z.literal("GitHub"),
|
||||||
|
connection: z.literal(AppConnection.GitHub),
|
||||||
|
destination: z.literal(SecretSync.GitHub),
|
||||||
|
canImportSecrets: z.literal(false)
|
||||||
|
});
|
38
backend/src/services/secret-sync/github/github-sync-types.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TGitHubConnection } from "@app/services/app-connection/github";
|
||||||
|
|
||||||
|
import { CreateGitHubSyncSchema, GitHubSyncListItemSchema, GitHubSyncSchema } from "./github-sync-schemas";
|
||||||
|
|
||||||
|
export type TGitHubSync = z.infer<typeof GitHubSyncSchema>;
|
||||||
|
|
||||||
|
export type TGitHubSyncInput = z.infer<typeof CreateGitHubSyncSchema>;
|
||||||
|
|
||||||
|
export type TGitHubSyncListItem = z.infer<typeof GitHubSyncListItemSchema>;
|
||||||
|
|
||||||
|
export type TGitHubSyncWithCredentials = TGitHubSync & {
|
||||||
|
connection: TGitHubConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGitHubSecret = {
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
visibility?: "all" | "private" | "selected";
|
||||||
|
selected_repositories_url?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGitHubPublicKey = {
|
||||||
|
key_id: string;
|
||||||
|
key: string;
|
||||||
|
id?: number | undefined;
|
||||||
|
url?: string | undefined;
|
||||||
|
title?: string | undefined;
|
||||||
|
created_at?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGitHubSecretPayload = {
|
||||||
|
key_id: string;
|
||||||
|
secret_name: string;
|
||||||
|
encrypted_value: string;
|
||||||
|
};
|
4
backend/src/services/secret-sync/github/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./github-sync-constants";
|
||||||
|
export * from "./github-sync-fns";
|
||||||
|
export * from "./github-sync-schemas";
|
||||||
|
export * from "./github-sync-types";
|
212
backend/src/services/secret-sync/secret-sync-dal.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { TSecretSyncs } from "@app/db/schemas/secret-syncs";
|
||||||
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
|
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
|
||||||
|
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||||
|
|
||||||
|
export type TSecretSyncDALFactory = ReturnType<typeof secretSyncDALFactory>;
|
||||||
|
|
||||||
|
type SecretSyncFindFilter = Parameters<typeof buildFindFilter<TSecretSyncs>>[0];
|
||||||
|
|
||||||
|
const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: SecretSyncFindFilter; tx?: Knex }) => {
|
||||||
|
const query = (tx || db.replicaNode())(TableName.SecretSync)
|
||||||
|
.leftJoin(TableName.SecretFolder, `${TableName.SecretSync}.folderId`, `${TableName.SecretFolder}.id`)
|
||||||
|
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.join(TableName.AppConnection, `${TableName.SecretSync}.connectionId`, `${TableName.AppConnection}.id`)
|
||||||
|
.select(selectAllTableCols(TableName.SecretSync))
|
||||||
|
.select(
|
||||||
|
// environment
|
||||||
|
db.ref("name").withSchema(TableName.Environment).as("envName"),
|
||||||
|
db.ref("id").withSchema(TableName.Environment).as("envId"),
|
||||||
|
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
|
||||||
|
// entire connection
|
||||||
|
db.ref("name").withSchema(TableName.AppConnection).as("connectionName"),
|
||||||
|
db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"),
|
||||||
|
db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"),
|
||||||
|
db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"),
|
||||||
|
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"),
|
||||||
|
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
|
||||||
|
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
|
||||||
|
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
|
||||||
|
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt")
|
||||||
|
);
|
||||||
|
|
||||||
|
// prepends table name to filter keys to avoid ambiguous col references, skipping utility filters like $in, etc.
|
||||||
|
const prependTableName = (filterObj: object): SecretSyncFindFilter =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(filterObj).map(([key, value]) =>
|
||||||
|
key.startsWith("$") ? [key, prependTableName(value as object)] : [`${TableName.SecretSync}.${key}`, value]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||||
|
void query.where(buildFindFilter(prependTableName(filter)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandSecretSync = (
|
||||||
|
secretSync: Awaited<ReturnType<typeof baseSecretSyncQuery>>[number],
|
||||||
|
folder?: Awaited<ReturnType<TSecretFolderDALFactory["findSecretPathByFolderIds"]>>[number]
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
envId,
|
||||||
|
envName,
|
||||||
|
envSlug,
|
||||||
|
connectionApp,
|
||||||
|
connectionName,
|
||||||
|
connectionId,
|
||||||
|
connectionOrgId,
|
||||||
|
connectionEncryptedCredentials,
|
||||||
|
connectionMethod,
|
||||||
|
connectionDescription,
|
||||||
|
connectionCreatedAt,
|
||||||
|
connectionUpdatedAt,
|
||||||
|
connectionVersion,
|
||||||
|
...el
|
||||||
|
} = secretSync;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
connectionId,
|
||||||
|
environment: envId ? { id: envId, name: envName, slug: envSlug } : null,
|
||||||
|
connection: {
|
||||||
|
app: connectionApp,
|
||||||
|
id: connectionId,
|
||||||
|
name: connectionName,
|
||||||
|
orgId: connectionOrgId,
|
||||||
|
encryptedCredentials: connectionEncryptedCredentials,
|
||||||
|
method: connectionMethod,
|
||||||
|
description: connectionDescription,
|
||||||
|
createdAt: connectionCreatedAt,
|
||||||
|
updatedAt: connectionUpdatedAt,
|
||||||
|
version: connectionVersion
|
||||||
|
},
|
||||||
|
folder: folder
|
||||||
|
? {
|
||||||
|
id: folder.id,
|
||||||
|
path: folder.path
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secretSyncDALFactory = (
|
||||||
|
db: TDbClient,
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">
|
||||||
|
) => {
|
||||||
|
const secretSyncOrm = ormify(db, TableName.SecretSync);
|
||||||
|
|
||||||
|
const findById = async (id: string, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const secretSync = await baseSecretSyncQuery({
|
||||||
|
filter: { id },
|
||||||
|
db,
|
||||||
|
tx
|
||||||
|
}).first();
|
||||||
|
|
||||||
|
if (secretSync) {
|
||||||
|
// TODO (scott): replace with cached folder path once implemented
|
||||||
|
const [folderWithPath] = secretSync.folderId
|
||||||
|
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||||
|
: [];
|
||||||
|
return expandSecretSync(secretSync, folderWithPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find by ID - Secret Sync" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async (data: Parameters<(typeof secretSyncOrm)["create"]>[0]) => {
|
||||||
|
try {
|
||||||
|
const secretSync = (await secretSyncOrm.transaction(async (tx) => {
|
||||||
|
const sync = await secretSyncOrm.create(data, tx);
|
||||||
|
|
||||||
|
return baseSecretSyncQuery({
|
||||||
|
filter: { id: sync.id },
|
||||||
|
db,
|
||||||
|
tx
|
||||||
|
}).first();
|
||||||
|
}))!;
|
||||||
|
|
||||||
|
// TODO (scott): replace with cached folder path once implemented
|
||||||
|
const [folderWithPath] = secretSync.folderId
|
||||||
|
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||||
|
: [];
|
||||||
|
return expandSecretSync(secretSync, folderWithPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Create - Secret Sync" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateById = async (syncId: string, data: Parameters<(typeof secretSyncOrm)["updateById"]>[1]) => {
|
||||||
|
try {
|
||||||
|
const secretSync = (await secretSyncOrm.transaction(async (tx) => {
|
||||||
|
const sync = await secretSyncOrm.updateById(syncId, data, tx);
|
||||||
|
|
||||||
|
return baseSecretSyncQuery({
|
||||||
|
filter: { id: sync.id },
|
||||||
|
db,
|
||||||
|
tx
|
||||||
|
}).first();
|
||||||
|
}))!;
|
||||||
|
|
||||||
|
// TODO (scott): replace with cached folder path once implemented
|
||||||
|
const [folderWithPath] = secretSync.folderId
|
||||||
|
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||||
|
: [];
|
||||||
|
return expandSecretSync(secretSync, folderWithPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Update by ID - Secret Sync" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOne = async (filter: Parameters<(typeof secretSyncOrm)["findOne"]>[0], tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const secretSync = await baseSecretSyncQuery({ filter, db, tx }).first();
|
||||||
|
|
||||||
|
if (secretSync) {
|
||||||
|
// TODO (scott): replace with cached folder path once implemented
|
||||||
|
const [folderWithPath] = secretSync.folderId
|
||||||
|
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||||
|
: [];
|
||||||
|
return expandSecretSync(secretSync, folderWithPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find One - Secret Sync" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const find = async (filter: Parameters<(typeof secretSyncOrm)["find"]>[0], tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const secretSyncs = await baseSecretSyncQuery({ filter, db, tx });
|
||||||
|
|
||||||
|
if (!secretSyncs.length) return [];
|
||||||
|
|
||||||
|
const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
|
||||||
|
secretSyncs[0].projectId,
|
||||||
|
secretSyncs.filter((sync) => Boolean(sync.folderId)).map((sync) => sync.folderId!)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO (scott): replace with cached folder path once implemented
|
||||||
|
const folderRecord: Record<string, (typeof foldersWithPath)[number]> = {};
|
||||||
|
|
||||||
|
foldersWithPath.forEach((folder) => {
|
||||||
|
if (folder) folderRecord[folder.id] = folder;
|
||||||
|
});
|
||||||
|
|
||||||
|
return secretSyncs.map((secretSync) =>
|
||||||
|
expandSecretSync(secretSync, secretSync.folderId ? folderRecord[secretSync.folderId] : undefined)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find - Secret Sync" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...secretSyncOrm, findById, findOne, find, create, updateById };
|
||||||
|
};
|
15
backend/src/services/secret-sync/secret-sync-enums.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export enum SecretSync {
|
||||||
|
AWSParameterStore = "aws-parameter-store",
|
||||||
|
GitHub = "github"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SecretSyncInitialSyncBehavior {
|
||||||
|
OverwriteDestination = "overwrite-destination",
|
||||||
|
ImportPrioritizeSource = "import-prioritize-source",
|
||||||
|
ImportPrioritizeDestination = "import-prioritize-destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SecretSyncImportBehavior {
|
||||||
|
PrioritizeSource = "prioritize-source",
|
||||||
|
PrioritizeDestination = "prioritize-destination"
|
||||||
|
}
|
23
backend/src/services/secret-sync/secret-sync-errors.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export class SecretSyncError extends Error {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
error?: unknown;
|
||||||
|
|
||||||
|
secretKey?: string;
|
||||||
|
|
||||||
|
shouldRetry?: boolean;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
name,
|
||||||
|
error,
|
||||||
|
secretKey,
|
||||||
|
message,
|
||||||
|
shouldRetry = true
|
||||||
|
}: { name?: string; error?: unknown; secretKey?: string; shouldRetry?: boolean; message?: string } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = name || "SecretSyncError";
|
||||||
|
this.error = error;
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
this.shouldRetry = shouldRetry;
|
||||||
|
}
|
||||||
|
}
|
127
backend/src/services/secret-sync/secret-sync-fns.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
|
||||||
|
AwsParameterStoreSyncFns
|
||||||
|
} from "@app/services/secret-sync/aws-parameter-store";
|
||||||
|
import { GITHUB_SYNC_LIST_OPTION, GithubSyncFns } from "@app/services/secret-sync/github";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||||
|
import {
|
||||||
|
TSecretMap,
|
||||||
|
TSecretSyncListItem,
|
||||||
|
TSecretSyncWithCredentials
|
||||||
|
} from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
|
||||||
|
[SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
|
||||||
|
[SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listSecretSyncOptions = () => {
|
||||||
|
return Object.values(SECRET_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
};
|
||||||
|
|
||||||
|
// const addAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => {
|
||||||
|
// let secretMap = { ...unprocessedSecretMap };
|
||||||
|
//
|
||||||
|
// const { appendSuffix, prependPrefix } = secretSync.syncOptions;
|
||||||
|
//
|
||||||
|
// if (appendSuffix || prependPrefix) {
|
||||||
|
// secretMap = {};
|
||||||
|
// Object.entries(unprocessedSecretMap).forEach(([key, value]) => {
|
||||||
|
// secretMap[`${prependPrefix || ""}${key}${appendSuffix || ""}`] = value;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return secretMap;
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// const stripAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => {
|
||||||
|
// let secretMap = { ...unprocessedSecretMap };
|
||||||
|
//
|
||||||
|
// const { appendSuffix, prependPrefix } = secretSync.syncOptions;
|
||||||
|
//
|
||||||
|
// if (appendSuffix || prependPrefix) {
|
||||||
|
// secretMap = {};
|
||||||
|
// Object.entries(unprocessedSecretMap).forEach(([key, value]) => {
|
||||||
|
// let processedKey = key;
|
||||||
|
//
|
||||||
|
// if (prependPrefix && processedKey.startsWith(prependPrefix)) {
|
||||||
|
// processedKey = processedKey.slice(prependPrefix.length);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (appendSuffix && processedKey.endsWith(appendSuffix)) {
|
||||||
|
// processedKey = processedKey.slice(0, -appendSuffix.length);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// secretMap[processedKey] = value;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return secretMap;
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const SecretSyncFns = {
|
||||||
|
syncSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => {
|
||||||
|
// const affixedSecretMap = addAffixes(secretSync, secretMap);
|
||||||
|
|
||||||
|
switch (secretSync.destination) {
|
||||||
|
case SecretSync.AWSParameterStore:
|
||||||
|
return AwsParameterStoreSyncFns.syncSecrets(secretSync, secretMap);
|
||||||
|
case SecretSync.GitHub:
|
||||||
|
return GithubSyncFns.syncSecrets(secretSync, secretMap);
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getSecrets: async (secretSync: TSecretSyncWithCredentials): Promise<TSecretMap> => {
|
||||||
|
let secretMap: TSecretMap;
|
||||||
|
switch (secretSync.destination) {
|
||||||
|
case SecretSync.AWSParameterStore:
|
||||||
|
secretMap = await AwsParameterStoreSyncFns.getSecrets(secretSync);
|
||||||
|
break;
|
||||||
|
case SecretSync.GitHub:
|
||||||
|
secretMap = await GithubSyncFns.getSecrets(secretSync);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretMap;
|
||||||
|
// return stripAffixes(secretSync, secretMap);
|
||||||
|
},
|
||||||
|
removeSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => {
|
||||||
|
// const affixedSecretMap = addAffixes(secretSync, secretMap);
|
||||||
|
|
||||||
|
switch (secretSync.destination) {
|
||||||
|
case SecretSync.AWSParameterStore:
|
||||||
|
return AwsParameterStoreSyncFns.removeSecrets(secretSync, secretMap);
|
||||||
|
case SecretSync.GitHub:
|
||||||
|
return GithubSyncFns.removeSecrets(secretSync, secretMap);
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSyncErrorMessage = (err: unknown): string => {
|
||||||
|
if (err instanceof SecretSyncError) {
|
||||||
|
return JSON.stringify({
|
||||||
|
secretKey: err.secretKey,
|
||||||
|
error: err.message ?? parseSyncErrorMessage(err.error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
return err?.response?.data ? JSON.stringify(err?.response?.data) : err?.message ?? "An unknown error occurred.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (err as Error)?.message || "An unknown error occurred.";
|
||||||
|
};
|
12
backend/src/services/secret-sync/secret-sync-maps.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
|
||||||
|
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||||
|
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
|
||||||
|
[SecretSync.GitHub]: "GitHub"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
|
||||||
|
[SecretSync.AWSParameterStore]: AppConnection.AWS,
|
||||||
|
[SecretSync.GitHub]: AppConnection.GitHub
|
||||||
|
};
|
955
backend/src/services/secret-sync/secret-sync-queue.ts
Normal file
@ -0,0 +1,955 @@
|
|||||||
|
import opentelemetry from "@opentelemetry/api";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { Job } from "bullmq";
|
||||||
|
|
||||||
|
import { ProjectMembershipRole, SecretType } from "@app/db/schemas";
|
||||||
|
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||||
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||||
|
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||||
|
import { ActorType } from "@app/services/auth/auth-type";
|
||||||
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
|
||||||
|
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||||
|
import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@app/services/secret/secret-fns";
|
||||||
|
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||||
|
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
||||||
|
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
||||||
|
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||||
|
import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
|
||||||
|
import { fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns";
|
||||||
|
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
|
||||||
|
import {
|
||||||
|
SecretSync,
|
||||||
|
SecretSyncImportBehavior,
|
||||||
|
SecretSyncInitialSyncBehavior
|
||||||
|
} from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||||
|
import { parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns";
|
||||||
|
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||||
|
import {
|
||||||
|
SecretSyncAction,
|
||||||
|
SecretSyncStatus,
|
||||||
|
TQueueSecretSyncImportSecretsByIdDTO,
|
||||||
|
TQueueSecretSyncRemoveSecretsByIdDTO,
|
||||||
|
TQueueSecretSyncsByPathDTO,
|
||||||
|
TQueueSecretSyncSyncSecretsByIdDTO,
|
||||||
|
TQueueSendSecretSyncActionFailedNotificationsDTO,
|
||||||
|
TSecretMap,
|
||||||
|
TSecretSyncImportSecretsDTO,
|
||||||
|
TSecretSyncRaw,
|
||||||
|
TSecretSyncRemoveSecretsDTO,
|
||||||
|
TSecretSyncSyncSecretsDTO,
|
||||||
|
TSecretSyncWithCredentials,
|
||||||
|
TSendSecretSyncFailedNotificationsJobDTO
|
||||||
|
} from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||||
|
import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
|
import { expandSecretReferencesFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
|
||||||
|
import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
|
||||||
|
import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||||
|
|
||||||
|
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
|
||||||
|
|
||||||
|
type TSecretSyncQueueFactoryDep = {
|
||||||
|
queueService: Pick<TQueueServiceFactory, "queue" | "start">;
|
||||||
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
|
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
|
||||||
|
folderDAL: TSecretFolderDALFactory;
|
||||||
|
secretV2BridgeDAL: Pick<
|
||||||
|
TSecretV2BridgeDALFactory,
|
||||||
|
| "findByFolderId"
|
||||||
|
| "find"
|
||||||
|
| "insertMany"
|
||||||
|
| "upsertSecretReferences"
|
||||||
|
| "findBySecretKeys"
|
||||||
|
| "bulkUpdate"
|
||||||
|
| "deleteMany"
|
||||||
|
>;
|
||||||
|
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||||
|
secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">;
|
||||||
|
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
|
||||||
|
projectDAL: TProjectDALFactory;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
projectBotDAL: TProjectBotDALFactory;
|
||||||
|
secretDAL: TSecretDALFactory;
|
||||||
|
secretVersionDAL: TSecretVersionDALFactory;
|
||||||
|
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
|
||||||
|
secretTagDAL: TSecretTagDALFactory;
|
||||||
|
secretVersionTagDAL: TSecretVersionTagDALFactory;
|
||||||
|
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
|
||||||
|
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
||||||
|
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SecretSyncActionJob = Job<
|
||||||
|
TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO
|
||||||
|
>;
|
||||||
|
|
||||||
|
const getRequeueDelay = (failureCount?: number) => {
|
||||||
|
if (!failureCount) return 0;
|
||||||
|
|
||||||
|
const baseDelay = 1000;
|
||||||
|
const maxDelay = 30000;
|
||||||
|
|
||||||
|
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
|
||||||
|
|
||||||
|
const jitter = delay * (0.5 + Math.random() * 0.5);
|
||||||
|
|
||||||
|
return jitter;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secretSyncQueueFactory = ({
|
||||||
|
queueService,
|
||||||
|
kmsService,
|
||||||
|
keyStore,
|
||||||
|
folderDAL,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
secretImportDAL,
|
||||||
|
secretSyncDAL,
|
||||||
|
auditLogService,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectDAL,
|
||||||
|
smtpService,
|
||||||
|
projectBotDAL,
|
||||||
|
secretDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretBlindIndexDAL,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
secretVersionV2BridgeDAL,
|
||||||
|
secretVersionTagV2BridgeDAL,
|
||||||
|
resourceMetadataDAL
|
||||||
|
}: TSecretSyncQueueFactoryDep) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
const integrationMeter = opentelemetry.metrics.getMeter("SecretSyncs");
|
||||||
|
const syncSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_sync_secrets_errors", {
|
||||||
|
description: "Secret Sync - sync secrets errors",
|
||||||
|
unit: "1"
|
||||||
|
});
|
||||||
|
const importSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_import_secrets_errors", {
|
||||||
|
description: "Secret Sync - import secrets errors",
|
||||||
|
unit: "1"
|
||||||
|
});
|
||||||
|
const removeSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_remove_secrets_errors", {
|
||||||
|
description: "Secret Sync - remove secrets errors",
|
||||||
|
unit: "1"
|
||||||
|
});
|
||||||
|
|
||||||
|
const $createManySecretsRawFn = createManySecretsRawFnFactory({
|
||||||
|
projectDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
secretDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretBlindIndexDAL,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
folderDAL,
|
||||||
|
kmsService,
|
||||||
|
secretVersionV2BridgeDAL,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
secretVersionTagV2BridgeDAL,
|
||||||
|
resourceMetadataDAL
|
||||||
|
});
|
||||||
|
|
||||||
|
const $updateManySecretsRawFn = updateManySecretsRawFnFactory({
|
||||||
|
projectDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
secretDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretBlindIndexDAL,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
folderDAL,
|
||||||
|
kmsService,
|
||||||
|
secretVersionV2BridgeDAL,
|
||||||
|
secretV2BridgeDAL,
|
||||||
|
secretVersionTagV2BridgeDAL,
|
||||||
|
resourceMetadataDAL
|
||||||
|
});
|
||||||
|
|
||||||
|
const $getInfisicalSecrets = async (
|
||||||
|
secretSync: TSecretSyncRaw | TSecretSyncWithCredentials,
|
||||||
|
includeImports = true
|
||||||
|
) => {
|
||||||
|
const { projectId, folderId, environment, folder } = secretSync;
|
||||||
|
|
||||||
|
if (!folderId || !environment || !folder)
|
||||||
|
throw new SecretSyncError({
|
||||||
|
message:
|
||||||
|
"Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path.",
|
||||||
|
shouldRetry: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretMap: TSecretMap = {};
|
||||||
|
|
||||||
|
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
|
type: KmsDataKey.SecretManager,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptSecretValue = (value?: Buffer | undefined | null) =>
|
||||||
|
value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "";
|
||||||
|
|
||||||
|
const { expandSecretReferences } = expandSecretReferencesFactory({
|
||||||
|
decryptSecretValue,
|
||||||
|
secretDAL: secretV2BridgeDAL,
|
||||||
|
folderDAL,
|
||||||
|
projectId,
|
||||||
|
canExpandValue: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
const secrets = await secretV2BridgeDAL.findByFolderId(folderId);
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
secrets.map(async (secret) => {
|
||||||
|
const secretKey = secret.key;
|
||||||
|
const secretValue = decryptSecretValue(secret.encryptedValue);
|
||||||
|
const expandedSecretValue = await expandSecretReferences({
|
||||||
|
environment: environment.slug,
|
||||||
|
secretPath: folder.path,
|
||||||
|
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||||
|
value: secretValue
|
||||||
|
});
|
||||||
|
secretMap[secretKey] = { value: expandedSecretValue || "" };
|
||||||
|
|
||||||
|
if (secret.encryptedComment) {
|
||||||
|
const commentValue = decryptSecretValue(secret.encryptedComment);
|
||||||
|
secretMap[secretKey].comment = commentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!includeImports) return secretMap;
|
||||||
|
|
||||||
|
const secretImports = await secretImportDAL.find({ folderId, isReplication: false });
|
||||||
|
|
||||||
|
if (secretImports.length) {
|
||||||
|
const importedSecrets = await fnSecretsV2FromImports({
|
||||||
|
decryptor: decryptSecretValue,
|
||||||
|
folderDAL,
|
||||||
|
secretDAL: secretV2BridgeDAL,
|
||||||
|
expandSecretReferences,
|
||||||
|
secretImportDAL,
|
||||||
|
secretImports,
|
||||||
|
hasSecretAccess: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) {
|
||||||
|
const importedSecret = importedSecrets[i].secrets[j];
|
||||||
|
if (!secretMap[importedSecret.key]) {
|
||||||
|
secretMap[importedSecret.key] = {
|
||||||
|
skipMultilineEncoding: importedSecret.skipMultilineEncoding,
|
||||||
|
comment: importedSecret.secretComment,
|
||||||
|
value: importedSecret.secretValue || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueSecretSyncSyncSecretsById = async (payload: TQueueSecretSyncSyncSecretsByIdDTO) =>
|
||||||
|
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncSyncSecrets, payload, {
|
||||||
|
delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked
|
||||||
|
attempts: 5,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 3000
|
||||||
|
},
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueSecretSyncImportSecretsById = async (payload: TQueueSecretSyncImportSecretsByIdDTO) =>
|
||||||
|
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncImportSecrets, payload, {
|
||||||
|
attempts: 1,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueSecretSyncRemoveSecretsById = async (payload: TQueueSecretSyncRemoveSecretsByIdDTO) =>
|
||||||
|
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncRemoveSecrets, payload, {
|
||||||
|
attempts: 1,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const $queueSendSecretSyncFailedNotifications = async (payload: TQueueSendSecretSyncActionFailedNotificationsDTO) => {
|
||||||
|
if (!appCfg.isSmtpConfigured) return;
|
||||||
|
|
||||||
|
await queueService.queue(
|
||||||
|
QueueName.AppConnectionSecretSync,
|
||||||
|
QueueJobs.SecretSyncSendActionFailedNotifications,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
jobId: `secret-sync-${payload.secretSync.id}-failed-notifications`,
|
||||||
|
attempts: 5,
|
||||||
|
delay: 1000 * 60,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 3000
|
||||||
|
},
|
||||||
|
removeOnFail: true,
|
||||||
|
removeOnComplete: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const $importSecrets = async (
|
||||||
|
secretSync: TSecretSyncWithCredentials,
|
||||||
|
importBehavior: SecretSyncImportBehavior
|
||||||
|
): Promise<TSecretMap> => {
|
||||||
|
const { projectId, environment, folder } = secretSync;
|
||||||
|
|
||||||
|
if (!environment || !folder)
|
||||||
|
throw new Error(
|
||||||
|
"Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path."
|
||||||
|
);
|
||||||
|
|
||||||
|
const importedSecrets = await SecretSyncFns.getSecrets(secretSync);
|
||||||
|
|
||||||
|
if (!Object.keys(importedSecrets).length) return {};
|
||||||
|
|
||||||
|
const importedSecretMap: TSecretMap = {};
|
||||||
|
|
||||||
|
const secretMap = await $getInfisicalSecrets(secretSync, false);
|
||||||
|
|
||||||
|
const secretsToCreate: Parameters<typeof $createManySecretsRawFn>[0]["secrets"] = [];
|
||||||
|
const secretsToUpdate: Parameters<typeof $updateManySecretsRawFn>[0]["secrets"] = [];
|
||||||
|
|
||||||
|
Object.entries(importedSecrets).forEach(([key, secretData]) => {
|
||||||
|
const { value, comment = "", skipMultilineEncoding } = secretData;
|
||||||
|
|
||||||
|
const secret = {
|
||||||
|
secretName: key,
|
||||||
|
secretValue: value,
|
||||||
|
type: SecretType.Shared,
|
||||||
|
secretComment: comment,
|
||||||
|
skipMultilineEncoding: skipMultilineEncoding ?? undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.hasOwn(secretMap, key)) {
|
||||||
|
secretsToUpdate.push(secret);
|
||||||
|
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData;
|
||||||
|
} else {
|
||||||
|
secretsToCreate.push(secret);
|
||||||
|
importedSecretMap[key] = secretData;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (secretsToCreate.length) {
|
||||||
|
await $createManySecretsRawFn({
|
||||||
|
projectId,
|
||||||
|
path: folder.path,
|
||||||
|
environment: environment.slug,
|
||||||
|
secrets: secretsToCreate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination && secretsToUpdate.length) {
|
||||||
|
await $updateManySecretsRawFn({
|
||||||
|
projectId,
|
||||||
|
path: folder.path,
|
||||||
|
environment: environment.slug,
|
||||||
|
secrets: secretsToUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return importedSecretMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => {
|
||||||
|
const {
|
||||||
|
data: { syncId, auditLogInfo }
|
||||||
|
} = job;
|
||||||
|
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
|
||||||
|
|
||||||
|
await secretSyncDAL.updateById(syncId, {
|
||||||
|
syncStatus: SecretSyncStatus.Running
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`SecretSync Sync [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
let isSynced = false;
|
||||||
|
let syncMessage: string | null = null;
|
||||||
|
let isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
connection: { orgId, encryptedCredentials }
|
||||||
|
} = secretSync;
|
||||||
|
|
||||||
|
const credentials = await decryptAppConnectionCredentials({
|
||||||
|
orgId,
|
||||||
|
encryptedCredentials,
|
||||||
|
kmsService
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretSyncWithCredentials = {
|
||||||
|
...secretSync,
|
||||||
|
connection: {
|
||||||
|
...secretSync.connection,
|
||||||
|
credentials
|
||||||
|
}
|
||||||
|
} as TSecretSyncWithCredentials;
|
||||||
|
|
||||||
|
const {
|
||||||
|
lastSyncedAt,
|
||||||
|
syncOptions: { initialSyncBehavior }
|
||||||
|
} = secretSyncWithCredentials;
|
||||||
|
|
||||||
|
const secretMap = await $getInfisicalSecrets(secretSync);
|
||||||
|
|
||||||
|
if (!lastSyncedAt && initialSyncBehavior !== SecretSyncInitialSyncBehavior.OverwriteDestination) {
|
||||||
|
const importedSecretMap = await $importSecrets(
|
||||||
|
secretSyncWithCredentials,
|
||||||
|
initialSyncBehavior === SecretSyncInitialSyncBehavior.ImportPrioritizeSource
|
||||||
|
? SecretSyncImportBehavior.PrioritizeSource
|
||||||
|
: SecretSyncImportBehavior.PrioritizeDestination
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.entries(importedSecretMap).forEach(([key, secretData]) => {
|
||||||
|
secretMap[key] = secretData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap);
|
||||||
|
|
||||||
|
isSynced = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
err,
|
||||||
|
`SecretSync Sync Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||||
|
syncSecretsErrorHistogram.record(1, {
|
||||||
|
version: 1,
|
||||||
|
destination: secretSync.destination,
|
||||||
|
syncId: secretSync.id,
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||||
|
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||||
|
name: err instanceof Error ? err.name : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMessage = parseSyncErrorMessage(err);
|
||||||
|
|
||||||
|
if (err instanceof SecretSyncError && !err.shouldRetry) {
|
||||||
|
isFinalAttempt = true;
|
||||||
|
} else {
|
||||||
|
// re-throw so job fails
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const ranAt = new Date();
|
||||||
|
const syncStatus = isSynced ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
|
||||||
|
|
||||||
|
await auditLogService.createAuditLog({
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
...(auditLogInfo ?? {
|
||||||
|
actor: {
|
||||||
|
type: ActorType.PLATFORM,
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
event: {
|
||||||
|
type: EventType.SECRET_SYNC_SYNC_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
syncId: secretSync.id,
|
||||||
|
syncOptions: secretSync.syncOptions,
|
||||||
|
destination: secretSync.destination,
|
||||||
|
destinationConfig: secretSync.destinationConfig,
|
||||||
|
folderId: secretSync.folderId,
|
||||||
|
connectionId: secretSync.connectionId,
|
||||||
|
jobRanAt: ranAt,
|
||||||
|
jobId: job.id!,
|
||||||
|
syncStatus,
|
||||||
|
syncMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSynced || isFinalAttempt) {
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
|
||||||
|
syncStatus,
|
||||||
|
lastSyncJobId: job.id,
|
||||||
|
lastSyncMessage: syncMessage,
|
||||||
|
lastSyncedAt: isSynced ? ranAt : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSynced) {
|
||||||
|
await $queueSendSecretSyncFailedNotifications({
|
||||||
|
secretSync: updatedSecretSync,
|
||||||
|
action: SecretSyncAction.SyncSecrets,
|
||||||
|
auditLogInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("SecretSync Sync Job with ID %s Completed", job.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => {
|
||||||
|
const {
|
||||||
|
data: { syncId, auditLogInfo, importBehavior }
|
||||||
|
} = job;
|
||||||
|
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
|
||||||
|
|
||||||
|
await secretSyncDAL.updateById(syncId, {
|
||||||
|
importStatus: SecretSyncStatus.Running
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`SecretSync Import [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
let isSuccess = false;
|
||||||
|
let importMessage: string | null = null;
|
||||||
|
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
connection: { orgId, encryptedCredentials }
|
||||||
|
} = secretSync;
|
||||||
|
|
||||||
|
const credentials = await decryptAppConnectionCredentials({
|
||||||
|
orgId,
|
||||||
|
encryptedCredentials,
|
||||||
|
kmsService
|
||||||
|
});
|
||||||
|
|
||||||
|
await $importSecrets(
|
||||||
|
{
|
||||||
|
...secretSync,
|
||||||
|
connection: {
|
||||||
|
...secretSync.connection,
|
||||||
|
credentials
|
||||||
|
}
|
||||||
|
} as TSecretSyncWithCredentials,
|
||||||
|
importBehavior
|
||||||
|
);
|
||||||
|
|
||||||
|
isSuccess = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
err,
|
||||||
|
`SecretSync Import Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||||
|
importSecretsErrorHistogram.record(1, {
|
||||||
|
version: 1,
|
||||||
|
destination: secretSync.destination,
|
||||||
|
syncId: secretSync.id,
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||||
|
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||||
|
name: err instanceof Error ? err.name : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
importMessage = parseSyncErrorMessage(err);
|
||||||
|
|
||||||
|
// re-throw so job fails
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
const ranAt = new Date();
|
||||||
|
const importStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
|
||||||
|
|
||||||
|
await auditLogService.createAuditLog({
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
...(auditLogInfo ?? {
|
||||||
|
actor: {
|
||||||
|
type: ActorType.PLATFORM,
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
event: {
|
||||||
|
type: EventType.SECRET_SYNC_IMPORT_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
syncId: secretSync.id,
|
||||||
|
syncOptions: secretSync.syncOptions,
|
||||||
|
destination: secretSync.destination,
|
||||||
|
destinationConfig: secretSync.destinationConfig,
|
||||||
|
folderId: secretSync.folderId,
|
||||||
|
connectionId: secretSync.connectionId,
|
||||||
|
jobRanAt: ranAt,
|
||||||
|
jobId: job.id!,
|
||||||
|
importStatus,
|
||||||
|
importMessage,
|
||||||
|
importBehavior
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccess || isFinalAttempt) {
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
|
||||||
|
importStatus,
|
||||||
|
lastImportJobId: job.id,
|
||||||
|
lastImportMessage: importMessage,
|
||||||
|
lastImportedAt: isSuccess ? ranAt : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
await $queueSendSecretSyncFailedNotifications({
|
||||||
|
secretSync: updatedSecretSync,
|
||||||
|
action: SecretSyncAction.ImportSecrets,
|
||||||
|
auditLogInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("SecretSync Import Job with ID %s Completed", job.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => {
|
||||||
|
const {
|
||||||
|
data: { syncId, auditLogInfo, deleteSyncOnComplete }
|
||||||
|
} = job;
|
||||||
|
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
|
||||||
|
|
||||||
|
await secretSyncDAL.updateById(syncId, {
|
||||||
|
removeStatus: SecretSyncStatus.Running
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`SecretSync Remove [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
let isSuccess = false;
|
||||||
|
let removeMessage: string | null = null;
|
||||||
|
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
connection: { orgId, encryptedCredentials }
|
||||||
|
} = secretSync;
|
||||||
|
|
||||||
|
const credentials = await decryptAppConnectionCredentials({
|
||||||
|
orgId,
|
||||||
|
encryptedCredentials,
|
||||||
|
kmsService
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretMap = await $getInfisicalSecrets(secretSync);
|
||||||
|
|
||||||
|
await SecretSyncFns.removeSecrets(
|
||||||
|
{
|
||||||
|
...secretSync,
|
||||||
|
connection: {
|
||||||
|
...secretSync.connection,
|
||||||
|
credentials
|
||||||
|
}
|
||||||
|
} as TSecretSyncWithCredentials,
|
||||||
|
secretMap
|
||||||
|
);
|
||||||
|
|
||||||
|
isSuccess = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
err,
|
||||||
|
`SecretSync Remove Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||||
|
removeSecretsErrorHistogram.record(1, {
|
||||||
|
version: 1,
|
||||||
|
destination: secretSync.destination,
|
||||||
|
syncId: secretSync.id,
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||||
|
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||||
|
name: err instanceof Error ? err.name : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMessage = parseSyncErrorMessage(err);
|
||||||
|
|
||||||
|
// re-throw so job fails
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
const ranAt = new Date();
|
||||||
|
const removeStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
|
||||||
|
|
||||||
|
await auditLogService.createAuditLog({
|
||||||
|
projectId: secretSync.projectId,
|
||||||
|
...(auditLogInfo ?? {
|
||||||
|
actor: {
|
||||||
|
type: ActorType.PLATFORM,
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
event: {
|
||||||
|
type: EventType.SECRET_SYNC_REMOVE_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
syncId: secretSync.id,
|
||||||
|
syncOptions: secretSync.syncOptions,
|
||||||
|
destination: secretSync.destination,
|
||||||
|
destinationConfig: secretSync.destinationConfig,
|
||||||
|
folderId: secretSync.folderId,
|
||||||
|
connectionId: secretSync.connectionId,
|
||||||
|
jobRanAt: ranAt,
|
||||||
|
jobId: job.id!,
|
||||||
|
removeStatus,
|
||||||
|
removeMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccess || isFinalAttempt) {
|
||||||
|
if (isSuccess && deleteSyncOnComplete) {
|
||||||
|
await secretSyncDAL.deleteById(secretSync.id);
|
||||||
|
} else {
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
|
||||||
|
removeStatus,
|
||||||
|
lastRemoveJobId: job.id,
|
||||||
|
lastRemoveMessage: removeMessage,
|
||||||
|
lastRemovedAt: isSuccess ? ranAt : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
await $queueSendSecretSyncFailedNotifications({
|
||||||
|
secretSync: updatedSecretSync,
|
||||||
|
action: SecretSyncAction.RemoveSecrets,
|
||||||
|
auditLogInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("SecretSync Remove Job with ID %s Completed", job.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const $sendSecretSyncFailedNotifications = async (job: TSendSecretSyncFailedNotificationsJobDTO) => {
|
||||||
|
const {
|
||||||
|
data: { secretSync, auditLogInfo, action }
|
||||||
|
} = job;
|
||||||
|
|
||||||
|
const { projectId, destination, name, folder, lastSyncMessage, lastRemoveMessage, lastImportMessage, environment } =
|
||||||
|
secretSync;
|
||||||
|
|
||||||
|
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
|
||||||
|
const project = await projectDAL.findById(projectId);
|
||||||
|
|
||||||
|
let projectAdmins = projectMembers.filter((member) =>
|
||||||
|
member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggeredByUserId =
|
||||||
|
auditLogInfo && auditLogInfo.actor.type === ActorType.USER && auditLogInfo.actor.metadata.userId;
|
||||||
|
|
||||||
|
// only notify triggering user if triggered by admin
|
||||||
|
if (triggeredByUserId && projectAdmins.map((admin) => admin.userId).includes(triggeredByUserId)) {
|
||||||
|
projectAdmins = projectAdmins.filter((admin) => admin.userId === triggeredByUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncDestination = SECRET_SYNC_NAME_MAP[destination as SecretSync];
|
||||||
|
|
||||||
|
let actionLabel: string;
|
||||||
|
let failureMessage: string | null | undefined;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case SecretSyncAction.ImportSecrets:
|
||||||
|
actionLabel = "Import";
|
||||||
|
failureMessage = lastImportMessage;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case SecretSyncAction.RemoveSecrets:
|
||||||
|
actionLabel = "Remove";
|
||||||
|
failureMessage = lastRemoveMessage;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case SecretSyncAction.SyncSecrets:
|
||||||
|
default:
|
||||||
|
actionLabel = `Sync`;
|
||||||
|
failureMessage = lastSyncMessage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
|
||||||
|
template: SmtpTemplates.SecretSyncFailed,
|
||||||
|
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
|
||||||
|
substitutions: {
|
||||||
|
syncName: name,
|
||||||
|
syncDestination,
|
||||||
|
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
|
||||||
|
failureMessage,
|
||||||
|
secretPath: folder?.path,
|
||||||
|
environment: environment?.name,
|
||||||
|
projectName: project.name,
|
||||||
|
syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueSecretSyncsSyncSecretsByPath = async ({
|
||||||
|
secretPath,
|
||||||
|
projectId,
|
||||||
|
environmentSlug
|
||||||
|
}: TQueueSecretSyncsByPathDTO) => {
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath);
|
||||||
|
|
||||||
|
if (!folder)
|
||||||
|
throw new Error(
|
||||||
|
`Could not find folder at path "${secretPath}" for environment with slug "${environmentSlug}" in project with ID "${projectId}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true });
|
||||||
|
|
||||||
|
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const $handleAcquireLockFailure = async (job: SecretSyncActionJob) => {
|
||||||
|
const { syncId, auditLogInfo } = job.data;
|
||||||
|
|
||||||
|
switch (job.name) {
|
||||||
|
case QueueJobs.SecretSyncSyncSecrets: {
|
||||||
|
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO;
|
||||||
|
|
||||||
|
if (failedToAcquireLockCount < 10) {
|
||||||
|
await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
syncStatus: SecretSyncStatus.Failed,
|
||||||
|
lastSyncMessage:
|
||||||
|
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||||
|
lastSyncJobId: job.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await $queueSendSecretSyncFailedNotifications({
|
||||||
|
secretSync,
|
||||||
|
action: SecretSyncAction.SyncSecrets,
|
||||||
|
auditLogInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Scott: the two cases below are unlikely to happen as we check the lock at the API level but including this as a fallback
|
||||||
|
case QueueJobs.SecretSyncImportSecrets: {
|
||||||
|
const secretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
importStatus: SecretSyncStatus.Failed,
|
||||||
|
lastImportMessage:
|
||||||
|
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||||
|
lastImportJobId: job.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await $queueSendSecretSyncFailedNotifications({
|
||||||
|
secretSync,
|
||||||
|
action: SecretSyncAction.ImportSecrets,
|
||||||
|
auditLogInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case QueueJobs.SecretSyncRemoveSecrets: {
|
||||||
|
const secretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
removeStatus: SecretSyncStatus.Failed,
|
||||||
|
lastRemoveMessage:
|
||||||
|
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||||
|
lastRemoveJobId: job.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await $queueSendSecretSyncFailedNotifications({
|
||||||
|
secretSync,
|
||||||
|
action: SecretSyncAction.RemoveSecrets,
|
||||||
|
auditLogInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||||
|
throw new Error(`Unhandled Secret Sync Job ${job.name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
queueService.start(QueueName.AppConnectionSecretSync, async (job) => {
|
||||||
|
if (job.name === QueueJobs.SecretSyncSendActionFailedNotifications) {
|
||||||
|
await $sendSecretSyncFailedNotifications(job as TSendSecretSyncFailedNotificationsJobDTO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { syncId } = job.data as
|
||||||
|
| TQueueSecretSyncSyncSecretsByIdDTO
|
||||||
|
| TQueueSecretSyncImportSecretsByIdDTO
|
||||||
|
| TQueueSecretSyncRemoveSecretsByIdDTO;
|
||||||
|
|
||||||
|
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lock = await keyStore.acquireLock(
|
||||||
|
[KeyStorePrefixes.SecretSyncLock(syncId)],
|
||||||
|
// scott: not sure on this duration; syncs can take excessive amounts of time so we need to keep it locked,
|
||||||
|
// but should always release below...
|
||||||
|
5 * 60 * 1000
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.info(`SecretSync Failed to acquire lock [syncId=${syncId}] [job=${job.name}]`);
|
||||||
|
|
||||||
|
await $handleAcquireLockFailure(job as SecretSyncActionJob);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (job.name) {
|
||||||
|
case QueueJobs.SecretSyncSyncSecrets:
|
||||||
|
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO);
|
||||||
|
break;
|
||||||
|
case QueueJobs.SecretSyncImportSecrets:
|
||||||
|
await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO);
|
||||||
|
break;
|
||||||
|
case QueueJobs.SecretSyncRemoveSecrets:
|
||||||
|
await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||||
|
throw new Error(`Unhandled Secret Sync Job ${job.name}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await lock.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
queueSecretSyncSyncSecretsById,
|
||||||
|
queueSecretSyncImportSecretsById,
|
||||||
|
queueSecretSyncRemoveSecretsById,
|
||||||
|
queueSecretSyncsSyncSecretsByPath
|
||||||
|
};
|
||||||
|
};
|
96
backend/src/services/secret-sync/secret-sync-schemas.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs";
|
||||||
|
import { SecretSyncs } from "@app/lib/api-docs";
|
||||||
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
|
import { slugSchema } from "@app/server/lib/schemas";
|
||||||
|
import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||||
|
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) =>
|
||||||
|
z.object({
|
||||||
|
initialSyncBehavior: (options.canImportSecrets
|
||||||
|
? z.nativeEnum(SecretSyncInitialSyncBehavior)
|
||||||
|
: z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination)
|
||||||
|
).describe(SecretSyncs.SYNC_OPTIONS(secretSync).INITIAL_SYNC_BEHAVIOR)
|
||||||
|
// prependPrefix: z
|
||||||
|
// .string()
|
||||||
|
// .trim()
|
||||||
|
// .transform((str) => str.toUpperCase())
|
||||||
|
// .optional()
|
||||||
|
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX),
|
||||||
|
// appendSuffix: z
|
||||||
|
// .string()
|
||||||
|
// .trim()
|
||||||
|
// .transform((str) => str.toUpperCase())
|
||||||
|
// .optional()
|
||||||
|
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
|
||||||
|
SecretSyncsSchema.omit({
|
||||||
|
destination: true,
|
||||||
|
destinationConfig: true,
|
||||||
|
syncOptions: true
|
||||||
|
}).extend({
|
||||||
|
// destination needs to be on the extended object for type differentiation
|
||||||
|
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig),
|
||||||
|
// join properties
|
||||||
|
projectId: z.string(),
|
||||||
|
connection: z.object({
|
||||||
|
app: z.literal(SECRET_SYNC_CONNECTION_MAP[destination]),
|
||||||
|
name: z.string(),
|
||||||
|
id: z.string().uuid()
|
||||||
|
}),
|
||||||
|
environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }).nullable(),
|
||||||
|
folder: z.object({ id: z.string(), path: z.string() }).nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
|
||||||
|
z.object({
|
||||||
|
name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name),
|
||||||
|
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(256, "Description cannot exceed 256 characters")
|
||||||
|
.nullish()
|
||||||
|
.describe(SecretSyncs.CREATE(destination).description),
|
||||||
|
connectionId: z.string().uuid().describe(SecretSyncs.CREATE(destination).connectionId),
|
||||||
|
environment: slugSchema({ field: "environment", max: 64 }).describe(SecretSyncs.CREATE(destination).environment),
|
||||||
|
secretPath: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Secret path required")
|
||||||
|
.transform(removeTrailingSlash)
|
||||||
|
.describe(SecretSyncs.CREATE(destination).secretPath),
|
||||||
|
isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled),
|
||||||
|
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
|
||||||
|
z.object({
|
||||||
|
name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(),
|
||||||
|
connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(256, "Description cannot exceed 256 characters")
|
||||||
|
.nullish()
|
||||||
|
.describe(SecretSyncs.UPDATE(destination).description),
|
||||||
|
environment: slugSchema({ field: "environment", max: 64 })
|
||||||
|
.optional()
|
||||||
|
.describe(SecretSyncs.UPDATE(destination).environment),
|
||||||
|
secretPath: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Invalid secret path")
|
||||||
|
.transform(removeTrailingSlash)
|
||||||
|
.optional()
|
||||||
|
.describe(SecretSyncs.UPDATE(destination).secretPath),
|
||||||
|
isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled),
|
||||||
|
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig)
|
||||||
|
.optional()
|
||||||
|
.describe(SecretSyncs.UPDATE(destination).syncOptions)
|
||||||
|
});
|
562
backend/src/services/secret-sync/secret-sync-service.ts
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
|
||||||
|
import { ActionProjectType } from "@app/db/schemas";
|
||||||
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSecretSyncActions,
|
||||||
|
ProjectPermissionSub
|
||||||
|
} from "@app/ee/services/permission/project-permission";
|
||||||
|
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||||
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
|
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||||
|
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||||
|
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||||
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
import { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
|
||||||
|
import {
|
||||||
|
SecretSyncStatus,
|
||||||
|
TCreateSecretSyncDTO,
|
||||||
|
TDeleteSecretSyncDTO,
|
||||||
|
TFindSecretSyncByIdDTO,
|
||||||
|
TFindSecretSyncByNameDTO,
|
||||||
|
TListSecretSyncsByProjectId,
|
||||||
|
TSecretSync,
|
||||||
|
TTriggerSecretSyncImportSecretsByIdDTO,
|
||||||
|
TTriggerSecretSyncRemoveSecretsByIdDTO,
|
||||||
|
TTriggerSecretSyncSyncSecretsByIdDTO,
|
||||||
|
TUpdateSecretSyncDTO
|
||||||
|
} from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
import { TSecretSyncDALFactory } from "./secret-sync-dal";
|
||||||
|
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
|
||||||
|
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
|
||||||
|
|
||||||
|
type TSecretSyncServiceFactoryDep = {
|
||||||
|
secretSyncDAL: TSecretSyncDALFactory;
|
||||||
|
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||||
|
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
|
||||||
|
keyStore: Pick<TKeyStoreFactory, "getItem">;
|
||||||
|
secretSyncQueue: Pick<
|
||||||
|
TSecretSyncQueueFactory,
|
||||||
|
"queueSecretSyncSyncSecretsById" | "queueSecretSyncImportSecretsById" | "queueSecretSyncRemoveSecretsById"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFactory>;
|
||||||
|
|
||||||
|
export const secretSyncServiceFactory = ({
|
||||||
|
secretSyncDAL,
|
||||||
|
folderDAL,
|
||||||
|
permissionService,
|
||||||
|
appConnectionService,
|
||||||
|
projectBotService,
|
||||||
|
secretSyncQueue,
|
||||||
|
keyStore
|
||||||
|
}: TSecretSyncServiceFactoryDep) => {
|
||||||
|
const listSecretSyncsByProjectId = async (
|
||||||
|
{ projectId, destination }: TListSecretSyncsByProjectId,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.Read,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
const secretSyncs = await secretSyncDAL.find({
|
||||||
|
...(destination && { destination }),
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
return secretSyncs as TSecretSync[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => {
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.Read,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return secretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSecretSyncByName = async (
|
||||||
|
{ destination, syncName, projectId }: TFindSecretSyncByNameDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const folders = await folderDAL.findByProjectId(projectId);
|
||||||
|
|
||||||
|
// we prevent conflicting names within a project so this will only return one at most
|
||||||
|
const [secretSync] = await secretSyncDAL.find({
|
||||||
|
name: syncName,
|
||||||
|
$in: {
|
||||||
|
folderId: folders.map((folder) => folder.id)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with name "${syncName}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.Read,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return secretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSecretSync = async (
|
||||||
|
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const { permission: projectPermission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||||
|
|
||||||
|
if (!shouldUseSecretV2Bridge)
|
||||||
|
throw new BadRequestError({ message: "Project version does not support Secret Syncs" });
|
||||||
|
|
||||||
|
ForbiddenError.from(projectPermission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.Create,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(projectPermission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Read,
|
||||||
|
subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||||
|
|
||||||
|
if (!folder)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
|
||||||
|
|
||||||
|
// validates permission to connect and app is valid for sync destination
|
||||||
|
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
|
||||||
|
|
||||||
|
const secretSync = await secretSyncDAL.transaction(async (tx) => {
|
||||||
|
const isConflictingName = Boolean(
|
||||||
|
(
|
||||||
|
await secretSyncDAL.find(
|
||||||
|
{
|
||||||
|
name: params.name,
|
||||||
|
projectId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConflictingName)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `A Secret Sync with the name "${params.name}" already exists for the project with ID "${folder.projectId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const sync = await secretSyncDAL.create({
|
||||||
|
folderId: folder.id,
|
||||||
|
...params,
|
||||||
|
...(params.isAutoSyncEnabled && { syncStatus: SecretSyncStatus.Pending }),
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
return sync;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (secretSync.isAutoSyncEnabled) await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id });
|
||||||
|
|
||||||
|
return secretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSecretSync = async (
|
||||||
|
{ destination, syncId, secretPath, environment, ...params }: TUpdateSecretSyncDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID ${syncId}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.Edit,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSecretSync = await secretSyncDAL.transaction(async (tx) => {
|
||||||
|
let { folderId } = secretSync;
|
||||||
|
|
||||||
|
if (params.connectionId) {
|
||||||
|
const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];
|
||||||
|
|
||||||
|
// validates permission to connect and app is valid for sync destination
|
||||||
|
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(secretPath && secretPath !== secretSync.folder?.path) ||
|
||||||
|
(environment && environment !== secretSync.environment?.slug)
|
||||||
|
) {
|
||||||
|
const updatedEnvironment = environment ?? secretSync.environment?.slug;
|
||||||
|
const updatedSecretPath = secretPath ?? secretSync.folder?.path;
|
||||||
|
|
||||||
|
if (!updatedEnvironment || !updatedSecretPath)
|
||||||
|
throw new BadRequestError({ message: "Must specify both source environment and secret path" });
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Read,
|
||||||
|
subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment: updatedEnvironment,
|
||||||
|
secretPath: updatedSecretPath
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const newFolder = await folderDAL.findBySecretPath(secretSync.projectId, updatedEnvironment, updatedSecretPath);
|
||||||
|
|
||||||
|
if (!newFolder)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${secretSync.projectId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
folderId = newFolder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.name && secretSync.name !== params.name) {
|
||||||
|
const isConflictingName = Boolean(
|
||||||
|
(
|
||||||
|
await secretSyncDAL.find(
|
||||||
|
{
|
||||||
|
name: params.name,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
)
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConflictingName)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `A Secret Sync with the name "${params.name}" already exists for project with ID "${secretSync.projectId}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAutoSyncEnabled = params.isAutoSyncEnabled ?? secretSync.isAutoSyncEnabled;
|
||||||
|
|
||||||
|
const updatedSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
...params,
|
||||||
|
...(isAutoSyncEnabled && folderId && { syncStatus: SecretSyncStatus.Pending }),
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedSync;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedSecretSync.isAutoSyncEnabled)
|
||||||
|
await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id });
|
||||||
|
|
||||||
|
return updatedSecretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSecretSync = async (
|
||||||
|
{ destination, syncId, removeSecrets }: TDeleteSecretSyncDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.Delete,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (removeSecrets) {
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.RemoveSecrets,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secretSync.folderId)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||||
|
|
||||||
|
if (isSyncJobRunning)
|
||||||
|
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||||
|
|
||||||
|
await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, deleteSyncOnComplete: true });
|
||||||
|
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
removeStatus: SecretSyncStatus.Pending
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedSecretSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
await secretSyncDAL.deleteById(syncId);
|
||||||
|
|
||||||
|
return secretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSecretSyncSyncSecretsById = async (
|
||||||
|
{ syncId, destination, ...params }: TTriggerSecretSyncSyncSecretsByIdDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.SyncSecrets,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!secretSync.folderId)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||||
|
|
||||||
|
if (isSyncJobRunning)
|
||||||
|
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||||
|
|
||||||
|
await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId, ...params });
|
||||||
|
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
syncStatus: SecretSyncStatus.Pending
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedSecretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSecretSyncImportSecretsById = async (
|
||||||
|
{ syncId, destination, ...params }: TTriggerSecretSyncImportSecretsByIdDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
if (!listSecretSyncOptions().find((option) => option.destination === destination)?.canImportSecrets) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `${SECRET_SYNC_NAME_MAP[destination]} does not support importing secrets.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.ImportSecrets,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!secretSync.folderId)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||||
|
|
||||||
|
if (isSyncJobRunning)
|
||||||
|
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||||
|
|
||||||
|
await secretSyncQueue.queueSecretSyncImportSecretsById({ syncId, ...params });
|
||||||
|
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
importStatus: SecretSyncStatus.Pending
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedSecretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSecretSyncRemoveSecretsById = async (
|
||||||
|
{ syncId, destination, ...params }: TTriggerSecretSyncRemoveSecretsByIdDTO,
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
const secretSync = await secretSyncDAL.findById(syncId);
|
||||||
|
|
||||||
|
if (!secretSync)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor: actor.type,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorAuthMethod: actor.authMethod,
|
||||||
|
actorOrgId: actor.orgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager,
|
||||||
|
projectId: secretSync.projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionSecretSyncActions.RemoveSecrets,
|
||||||
|
ProjectPermissionSub.SecretSyncs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!secretSync.folderId)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||||
|
|
||||||
|
if (isSyncJobRunning)
|
||||||
|
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||||
|
|
||||||
|
await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, ...params });
|
||||||
|
|
||||||
|
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||||
|
removeStatus: SecretSyncStatus.Pending
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedSecretSync as TSecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
listSecretSyncOptions,
|
||||||
|
listSecretSyncsByProjectId,
|
||||||
|
findSecretSyncById,
|
||||||
|
findSecretSyncByName,
|
||||||
|
createSecretSync,
|
||||||
|
updateSecretSync,
|
||||||
|
deleteSecretSync,
|
||||||
|
triggerSecretSyncSyncSecretsById,
|
||||||
|
triggerSecretSyncImportSecretsById,
|
||||||
|
triggerSecretSyncRemoveSecretsById
|
||||||
|
};
|
||||||
|
};
|
148
backend/src/services/secret-sync/secret-sync-types.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
|
||||||
|
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { QueueJobs } from "@app/queue";
|
||||||
|
import {
|
||||||
|
TGitHubSync,
|
||||||
|
TGitHubSyncInput,
|
||||||
|
TGitHubSyncListItem,
|
||||||
|
TGitHubSyncWithCredentials
|
||||||
|
} from "@app/services/secret-sync/github";
|
||||||
|
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
|
||||||
|
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TAwsParameterStoreSync,
|
||||||
|
TAwsParameterStoreSyncInput,
|
||||||
|
TAwsParameterStoreSyncListItem,
|
||||||
|
TAwsParameterStoreSyncWithCredentials
|
||||||
|
} from "./aws-parameter-store";
|
||||||
|
|
||||||
|
export type TSecretSync = TAwsParameterStoreSync | TGitHubSync;
|
||||||
|
|
||||||
|
export type TSecretSyncWithCredentials = TAwsParameterStoreSyncWithCredentials | TGitHubSyncWithCredentials;
|
||||||
|
|
||||||
|
export type TSecretSyncInput = TAwsParameterStoreSyncInput | TGitHubSyncInput;
|
||||||
|
|
||||||
|
export type TSecretSyncListItem = TAwsParameterStoreSyncListItem | TGitHubSyncListItem;
|
||||||
|
|
||||||
|
export type TSyncOptionsConfig = {
|
||||||
|
canImportSecrets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TListSecretSyncsByProjectId = {
|
||||||
|
projectId: string;
|
||||||
|
destination?: SecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TFindSecretSyncByIdDTO = {
|
||||||
|
syncId: string;
|
||||||
|
destination: SecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TFindSecretSyncByNameDTO = {
|
||||||
|
syncName: string;
|
||||||
|
projectId: string;
|
||||||
|
destination: SecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateSecretSyncDTO = Pick<TSecretSync, "syncOptions" | "destinationConfig" | "name" | "connectionId"> & {
|
||||||
|
destination: SecretSync;
|
||||||
|
projectId: string;
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
isAutoSyncEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateSecretSyncDTO = Partial<Omit<TCreateSecretSyncDTO, "projectId">> & {
|
||||||
|
syncId: string;
|
||||||
|
destination: SecretSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDeleteSecretSyncDTO = {
|
||||||
|
destination: SecretSync;
|
||||||
|
syncId: string;
|
||||||
|
removeSecrets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">;
|
||||||
|
|
||||||
|
export enum SecretSyncStatus {
|
||||||
|
Pending = "pending",
|
||||||
|
Running = "running",
|
||||||
|
Succeeded = "succeeded",
|
||||||
|
Failed = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SecretSyncAction {
|
||||||
|
SyncSecrets = "sync-secrets",
|
||||||
|
ImportSecrets = "import-secrets",
|
||||||
|
RemoveSecrets = "remove-secrets"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TSecretSyncRaw = NonNullable<Awaited<ReturnType<TSecretSyncDALFactory["findById"]>>>;
|
||||||
|
|
||||||
|
export type TQueueSecretSyncsByPathDTO = {
|
||||||
|
secretPath: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TQueueSecretSyncSyncSecretsByIdDTO = {
|
||||||
|
syncId: string;
|
||||||
|
failedToAcquireLockCount?: number;
|
||||||
|
auditLogInfo?: AuditLogInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTriggerSecretSyncSyncSecretsByIdDTO = {
|
||||||
|
destination: SecretSync;
|
||||||
|
} & TQueueSecretSyncSyncSecretsByIdDTO;
|
||||||
|
|
||||||
|
export type TQueueSecretSyncImportSecretsByIdDTO = {
|
||||||
|
syncId: string;
|
||||||
|
importBehavior: SecretSyncImportBehavior;
|
||||||
|
auditLogInfo?: AuditLogInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTriggerSecretSyncImportSecretsByIdDTO = {
|
||||||
|
destination: SecretSync;
|
||||||
|
} & TQueueSecretSyncImportSecretsByIdDTO;
|
||||||
|
|
||||||
|
export type TQueueSecretSyncRemoveSecretsByIdDTO = {
|
||||||
|
syncId: string;
|
||||||
|
auditLogInfo?: AuditLogInfo;
|
||||||
|
deleteSyncOnComplete?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTriggerSecretSyncRemoveSecretsByIdDTO = {
|
||||||
|
destination: SecretSync;
|
||||||
|
} & TQueueSecretSyncRemoveSecretsByIdDTO;
|
||||||
|
|
||||||
|
export type TQueueSendSecretSyncActionFailedNotificationsDTO = {
|
||||||
|
secretSync: TSecretSyncRaw;
|
||||||
|
auditLogInfo?: AuditLogInfo;
|
||||||
|
action: SecretSyncAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSecretSyncSyncSecretsDTO = Job<TQueueSecretSyncSyncSecretsByIdDTO, void, QueueJobs.SecretSyncSyncSecrets>;
|
||||||
|
export type TSecretSyncImportSecretsDTO = Job<
|
||||||
|
TQueueSecretSyncImportSecretsByIdDTO,
|
||||||
|
void,
|
||||||
|
QueueJobs.SecretSyncSyncSecrets
|
||||||
|
>;
|
||||||
|
export type TSecretSyncRemoveSecretsDTO = Job<
|
||||||
|
TQueueSecretSyncRemoveSecretsByIdDTO,
|
||||||
|
void,
|
||||||
|
QueueJobs.SecretSyncSyncSecrets
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TSendSecretSyncFailedNotificationsJobDTO = Job<
|
||||||
|
TQueueSendSecretSyncActionFailedNotificationsDTO,
|
||||||
|
void,
|
||||||
|
QueueJobs.SecretSyncSendActionFailedNotifications
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TSecretMap = Record<
|
||||||
|
string,
|
||||||
|
{ value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined }
|
||||||
|
>;
|
@ -29,6 +29,7 @@ import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@a
|
|||||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||||
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
||||||
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
||||||
|
import { TSecretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-queue";
|
||||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||||
|
|
||||||
import { ActorType } from "../auth/auth-type";
|
import { ActorType } from "../auth/auth-type";
|
||||||
@ -107,6 +108,7 @@ type TSecretQueueFactoryDep = {
|
|||||||
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||||
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
|
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
|
||||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||||
|
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGetSecrets = {
|
export type TGetSecrets = {
|
||||||
@ -166,7 +168,8 @@ export const secretQueueFactory = ({
|
|||||||
orgService,
|
orgService,
|
||||||
projectUserMembershipRoleDAL,
|
projectUserMembershipRoleDAL,
|
||||||
projectKeyDAL,
|
projectKeyDAL,
|
||||||
resourceMetadataDAL
|
resourceMetadataDAL,
|
||||||
|
secretSyncQueue
|
||||||
}: TSecretQueueFactoryDep) => {
|
}: TSecretQueueFactoryDep) => {
|
||||||
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
|
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
|
||||||
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
|
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
|
||||||
@ -633,6 +636,9 @@ export const secretQueueFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await secretSyncQueue.queueSecretSyncsSyncSecretsByPath({ projectId, environmentSlug: environment, secretPath });
|
||||||
|
|
||||||
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
|
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
|
||||||
if (!excludeReplication) {
|
if (!excludeReplication) {
|
||||||
await replicateSecrets({
|
await replicateSecrets({
|
||||||
|
@ -35,6 +35,7 @@ export enum SmtpTemplates {
|
|||||||
ScimUserProvisioned = "scimUserProvisioned.handlebars",
|
ScimUserProvisioned = "scimUserProvisioned.handlebars",
|
||||||
PkiExpirationAlert = "pkiExpirationAlert.handlebars",
|
PkiExpirationAlert = "pkiExpirationAlert.handlebars",
|
||||||
IntegrationSyncFailed = "integrationSyncFailed.handlebars",
|
IntegrationSyncFailed = "integrationSyncFailed.handlebars",
|
||||||
|
SecretSyncFailed = "secretSyncFailed.handlebars",
|
||||||
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
|
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
|
||||||
ExternalImportFailed = "externalImportFailed.handlebars",
|
ExternalImportFailed = "externalImportFailed.handlebars",
|
||||||
ExternalImportStarted = "externalImportStarted.handlebars"
|
ExternalImportStarted = "externalImportStarted.handlebars"
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||||
|
<title>{{syncDestination}} Sync "{{syncName}}" Failed</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Infisical</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>{{content}}</p>
|
||||||
|
<a href="{{syncUrl}}">
|
||||||
|
View in Infisical.
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<p><strong>Name</strong>: {{syncName}}</p>
|
||||||
|
<p><strong>Destination</strong>: {{syncDestination}}</p>
|
||||||
|
<p><strong>Project</strong>: {{projectName}}</p>
|
||||||
|
{{#if environment}}
|
||||||
|
<p><strong>Environment</strong>: {{environment}}</p>
|
||||||
|
{{/if}}
|
||||||
|
{{#if secretPath}}
|
||||||
|
<p><strong>Secret Path</strong>: {{secretPath}}</p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if failureMessage}}
|
||||||
|
<p><b>Reason: </b>{{failureMessage}}</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{emailFooter}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
@ -315,7 +315,11 @@ var loginCmd = &cobra.Command{
|
|||||||
credential, err := authStrategies[strategy](cmd, infisicalClient)
|
credential, err := authStrategies[strategy](cmd, infisicalClient)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.HandleError(fmt.Errorf("unable to authenticate with %s [err=%v]", formatAuthMethod(loginMethod), err))
|
euErrorMessage := ""
|
||||||
|
if strings.HasPrefix(config.INFISICAL_URL, util.INFISICAL_DEFAULT_US_URL) {
|
||||||
|
euErrorMessage = fmt.Sprintf("\nIf you are using the Infisical Cloud Europe Region, please switch to it by using the \"--domain %s\" flag.", util.INFISICAL_DEFAULT_EU_URL)
|
||||||
|
}
|
||||||
|
util.HandleError(fmt.Errorf("unable to authenticate with %s [err=%v].%s", formatAuthMethod(loginMethod), err, euErrorMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
if plainOutput {
|
if plainOutput {
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Available"
|
||||||
|
openapi: "GET /api/v1/app-connections/aws/available"
|
||||||
|
---
|
@ -1,4 +1,4 @@
|
|||||||
---
|
---
|
||||||
title: "Get by Name"
|
title: "Get by Name"
|
||||||
openapi: "GET /api/v1/app-connections/aws/name/{connectionName}"
|
openapi: "GET /api/v1/app-connections/aws/connection-name/{connectionName}"
|
||||||
---
|
---
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Available"
|
||||||
|
openapi: "GET /api/v1/app-connections/github/available"
|
||||||
|
---
|
@ -1,4 +1,4 @@
|
|||||||
---
|
---
|
||||||
title: "Get by Name"
|
title: "Get by Name"
|
||||||
openapi: "GET /api/v1/app-connections/github/name/{connectionName}"
|
openapi: "GET /api/v1/app-connections/github/connection-name/{connectionName}"
|
||||||
---
|
---
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Create"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/aws-parameter-store"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Delete"
|
||||||
|
openapi: "DELETE /api/v1/secret-syncs/aws-parameter-store/{syncId}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Get by ID"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/aws-parameter-store/{syncId}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Get by Name"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/aws-parameter-store/sync-name/{syncName}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Import Secrets"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/import-secrets"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "List"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/aws-parameter-store"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Remove Secrets"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/remove-secrets"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Sync Secrets"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/sync-secrets"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Update"
|
||||||
|
openapi: "PATCH /api/v1/secret-syncs/aws-parameter-store/{syncId}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Create"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/github"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Delete"
|
||||||
|
openapi: "DELETE /api/v1/secret-syncs/github/{syncId}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Get by ID"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/github/{syncId}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Get by Name"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/github/sync-name/{syncName}"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "List"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/github"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Remove Secrets"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/github/{syncId}/remove-secrets"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Sync Secrets"
|
||||||
|
openapi: "POST /api/v1/secret-syncs/github/{syncId}/sync-secrets"
|
||||||
|
---
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Update"
|
||||||
|
openapi: "PATCH /api/v1/secret-syncs/github/{syncId}"
|
||||||
|
---
|
4
docs/api-reference/endpoints/secret-syncs/list.mdx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "List"
|
||||||
|
openapi: "GET /api/v1/secret-syncs"
|
||||||
|
---
|
4
docs/api-reference/endpoints/secret-syncs/options.mdx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Options"
|
||||||
|
openapi: "GET /api/v1/secret-syncs/options"
|
||||||
|
---
|
@ -11,6 +11,7 @@ description: "Infisical CLI command overview"
|
|||||||
| `init` | Used to link a local project to the platform. |
|
| `init` | Used to link a local project to the platform. |
|
||||||
| `run` | Used to inject envars from the platform into an application process. |
|
| `run` | Used to inject envars from the platform into an application process. |
|
||||||
| `vault` | Used to manage where your login credentials are stored at rest |
|
| `vault` | Used to manage where your login credentials are stored at rest |
|
||||||
|
|
||||||
## Global options
|
## Global options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|
@ -5,7 +5,7 @@ title: "Python"
|
|||||||
This guide demonstrates how to use Infisical to manage secrets for your Python stack from local development to production. It uses:
|
This guide demonstrates how to use Infisical to manage secrets for your Python stack from local development to production. It uses:
|
||||||
|
|
||||||
- Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets.
|
- Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets.
|
||||||
- The [infisical-python](https://pypi.org/project/infisical-python/) Python client SDK to fetch secrets back to your Python application on demand.
|
- The [infisicalsdk](https://pypi.org/project/infisicalsdk/) Python client SDK to fetch secrets back to your Python application on demand.
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
@ -36,40 +36,38 @@ python3 -m venv env
|
|||||||
source env/bin/activate
|
source env/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
Install Flask and [infisical-python](https://pypi.org/project/infisical-python/), the client Python SDK for Infisical.
|
Install Flask and [infisicalsdk](https://pypi.org/project/infisicalsdk/), the client Python SDK for Infisical.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
pip install flask infisical-python
|
pip install flask infisicalsdk
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, create an `app.py` file containing the application code.
|
Finally, create an `app.py` file containing the application code.
|
||||||
|
|
||||||
```py
|
```py
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod
|
from infisical_sdk import InfisicalSDKClient
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
client = InfisicalClient(ClientSettings(
|
client = InfisicalSDKClient(host="https://app.infisical.com") # host is optional, defaults to https://app.infisical.com
|
||||||
auth=AuthenticationOptions(
|
|
||||||
universal_auth=UniversalAuthMethod(
|
client.auth.universal_auth.login(
|
||||||
client_id="CLIENT_ID",
|
"<machine-identity-client-id>",
|
||||||
client_secret="CLIENT_SECRET",
|
"<machine-identity-client-secret>"
|
||||||
)
|
)
|
||||||
)
|
|
||||||
))
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def hello_world():
|
def hello_world():
|
||||||
# access value
|
# access value
|
||||||
|
name = client.secrets.get_secret_by_name(
|
||||||
|
secret_name="NAME",
|
||||||
|
project_id="<project-id>",
|
||||||
|
environment_slug="dev",
|
||||||
|
secret_path="/"
|
||||||
|
)
|
||||||
|
|
||||||
name = client.getSecret(options=GetSecretOptions(
|
return f"Hello! My name is: {name.secretValue}"
|
||||||
environment="dev",
|
|
||||||
project_id="PROJECT_ID",
|
|
||||||
secret_name="NAME"
|
|
||||||
))
|
|
||||||
|
|
||||||
return f"Hello! My name is: {name.secret_value}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Here, we initialized a `client` instance of the Infisical Python SDK with the Infisical Token
|
Here, we initialized a `client` instance of the Infisical Python SDK with the Infisical Token
|
||||||
@ -89,15 +87,6 @@ At this stage, you know how to fetch secrets from Infisical back to your Python
|
|||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
<Accordion title="Isn't it inefficient if my app makes a request every time it needs a secret?">
|
|
||||||
The client SDK caches every secret and implements a 5-minute waiting period before
|
|
||||||
re-requesting it. The waiting period can be controlled by setting the `cacheTTL` parameter at
|
|
||||||
the time of initializing the client.
|
|
||||||
</Accordion>
|
|
||||||
<Accordion title="What if a request for a secret fails?">
|
|
||||||
The SDK caches every secret and falls back to the cached value if a request fails. If no cached
|
|
||||||
value ever-existed, the SDK falls back to whatever value is on `process.env`.
|
|
||||||
</Accordion>
|
|
||||||
<Accordion title="What's the point if I still have to manage a token for the SDK?">
|
<Accordion title="What's the point if I still have to manage a token for the SDK?">
|
||||||
The token enables the SDK to authenticate with Infisical to fetch back your secrets.
|
The token enables the SDK to authenticate with Infisical to fetch back your secrets.
|
||||||
Although the SDK requires you to pass in a token, it enables greater efficiency and security
|
Although the SDK requires you to pass in a token, it enables greater efficiency and security
|
||||||
@ -114,6 +103,6 @@ At this stage, you know how to fetch secrets from Infisical back to your Python
|
|||||||
|
|
||||||
See also:
|
See also:
|
||||||
|
|
||||||
- Explore the [Python SDK](https://github.com/Infisical/sdk/tree/main/crates/infisical-py)
|
- Explore the [Python SDK](https://github.com/Infisical/python-sdk-official)
|
||||||
|
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO."
|
|||||||
|
|
||||||
Back in the **Set up Single Sign-On with SAML** screen, select **Edit** in the **Attributes & Claims** section and configure the following map:
|
Back in the **Set up Single Sign-On with SAML** screen, select **Edit** in the **Attributes & Claims** section and configure the following map:
|
||||||
|
|
||||||
- `email -> user.userprinciplename`
|
- `email -> user.userprincipalname`
|
||||||
- `firstName -> user.givenname`
|
- `firstName -> user.givenname`
|
||||||
- `lastName -> user.surname`
|
- `lastName -> user.surname`
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 968 KiB |
After Width: | Height: | Size: 615 KiB |
After Width: | Height: | Size: 610 KiB |
After Width: | Height: | Size: 659 KiB |
After Width: | Height: | Size: 646 KiB |
After Width: | Height: | Size: 591 KiB |
After Width: | Height: | Size: 571 KiB |
BIN
docs/images/secret-syncs/general/secret-sync-tab.png
Normal file
After Width: | Height: | Size: 950 KiB |
BIN
docs/images/secret-syncs/github/github-created.png
Normal file
After Width: | Height: | Size: 996 KiB |
BIN
docs/images/secret-syncs/github/github-destination.png
Normal file
After Width: | Height: | Size: 623 KiB |
BIN
docs/images/secret-syncs/github/github-details.png
Normal file
After Width: | Height: | Size: 617 KiB |