Compare commits

...

6 Commits

63 changed files with 3463 additions and 124 deletions

View File

@ -80,6 +80,7 @@ import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-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 { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
import { TSlackServiceFactory } from "@app/services/slack/slack-service";
@ -210,6 +211,7 @@ declare module "fastify" {
projectTemplate: TProjectTemplateServiceFactory;
totp: TTotpServiceFactory;
appConnection: TAppConnectionServiceFactory;
secretSync: TSecretSyncServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@ -369,6 +369,7 @@ import {
TExternalGroupOrgRoleMappingsInsert,
TExternalGroupOrgRoleMappingsUpdate
} from "@app/db/schemas/external-group-org-role-mappings";
import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs";
import {
TSecretV2TagJunction,
TSecretV2TagJunctionInsert,
@ -892,5 +893,6 @@ declare module "knex/types/tables" {
TAppConnectionsInsert,
TAppConnectionsUpdate
>;
[TableName.SecretSync]: KnexOriginal.CompositeTableType<TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate>;
}
}

View File

@ -0,0 +1,46 @@
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("isEnabled").notNullable().defaultTo(true);
t.integer("version").defaultTo(1).notNullable();
t.jsonb("destinationConfig").notNullable();
t.jsonb("syncOptions").notNullable();
t.uuid("folderId").notNullable();
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.uuid("connectionId").notNullable();
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
t.timestamps(true, true, true);
// sync
t.string("syncStatus");
t.string("lastSyncJobId");
t.string("lastSyncMessage");
t.datetime("lastSyncedAt");
// import
t.string("importStatus");
t.string("lastImportJobId");
t.string("lastImportMessage");
t.datetime("lastImportedAt");
// erase
t.string("eraseStatus");
t.string("lastEraseJobId");
t.string("lastEraseMessage");
t.datetime("lastErasedAt");
});
await createOnUpdateTrigger(knex, TableName.SecretSync);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretSync);
await dropOnUpdateTrigger(knex, TableName.SecretSync);
}

View File

@ -130,7 +130,8 @@ export enum TableName {
WorkflowIntegrations = "workflow_integrations",
SlackIntegrations = "slack_integrations",
ProjectSlackConfigs = "project_slack_configs",
AppConnection = "app_connections"
AppConnection = "app_connections",
SecretSync = "secret_syncs"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";

View File

@ -0,0 +1,39 @@
// 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(),
isEnabled: z.boolean().default(true),
version: z.number().default(1),
destinationConfig: z.unknown(),
syncOptions: z.unknown(),
folderId: z.string().uuid(),
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(),
eraseStatus: z.string().nullable().optional(),
lastEraseJobId: z.string().nullable().optional(),
lastEraseMessage: z.string().nullable().optional(),
lastErasedAt: 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>>;

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas";
import { OrgPermissionSchema } from "@app/ee/services/permission/org-permission";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@ -24,7 +25,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
),
name: z.string().trim(),
description: z.string().trim().nullish(),
permissions: z.any().array()
permissions: OrgPermissionSchema.array()
}),
response: {
200: z.object({
@ -96,7 +97,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
.optional(),
name: z.string().trim().optional(),
description: z.string().trim().nullish(),
permissions: z.any().array().optional()
permissions: OrgPermissionSchema.array().optional()
}),
response: {
200: z.object({

View File

@ -79,7 +79,8 @@ export const auditLogServiceFactory = ({
}
// add all cases in which project id or org id cannot be added
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);

View File

@ -13,6 +13,13 @@ import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
TCreateSecretSyncDTO,
TDeleteSecretSyncDTO,
TSecretSyncRaw,
TUpdateSecretSyncDTO
} from "@app/services/secret-sync/secret-sync-types";
export type TListProjectAuditLogDTO = {
filter: {
@ -226,10 +233,19 @@ export enum EventType {
DELETE_PROJECT_TEMPLATE = "delete-project-template",
APPLY_PROJECT_TEMPLATE = "apply-project-template",
GET_APP_CONNECTIONS = "get-app-connections",
GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details",
GET_APP_CONNECTION = "get-app-connection",
CREATE_APP_CONNECTION = "create-app-connection",
UPDATE_APP_CONNECTION = "update-app-connection",
DELETE_APP_CONNECTION = "delete-app-connection"
DELETE_APP_CONNECTION = "delete-app-connection",
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",
SYNC_SECRET_SYNC = "sync-secret-sync",
IMPORT_SECRET_SYNC = "import-secret-sync",
ERASE_SECRET_SYNC = "erase-secret-sync"
}
interface UserActorMetadata {
@ -1883,6 +1899,15 @@ interface GetAppConnectionsEvent {
};
}
interface GetAvailableAppConnectionsDetailsEvent {
type: EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS;
metadata: {
app?: AppConnection;
count: number;
connectionIds: string[];
};
}
interface GetAppConnectionEvent {
type: EventType.GET_APP_CONNECTION;
metadata: {
@ -1907,6 +1932,77 @@ interface DeleteAppConnectionEvent {
};
}
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: TCreateSecretSyncDTO & { syncId: string };
}
interface UpdateSecretSyncEvent {
type: EventType.UPDATE_SECRET_SYNC;
metadata: TUpdateSecretSyncDTO;
}
interface DeleteSecretSyncEvent {
type: EventType.DELETE_SECRET_SYNC;
metadata: TDeleteSecretSyncDTO;
}
interface SyncSecretSyncEvent {
type: EventType.SYNC_SECRET_SYNC;
metadata: Pick<
TSecretSyncRaw,
"syncOptions" | "destinationConfig" | "destination" | "syncStatus" | "environment" | "connectionId" | "folderId"
> & {
syncId: string;
syncMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
interface ImportSecretSyncEvent {
type: EventType.IMPORT_SECRET_SYNC;
metadata: Pick<
TSecretSyncRaw,
"syncOptions" | "destinationConfig" | "destination" | "importStatus" | "environment" | "connectionId" | "folderId"
> & {
syncId: string;
importMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
interface EraseSecretSyncEvent {
type: EventType.ERASE_SECRET_SYNC;
metadata: Pick<
TSecretSyncRaw,
"syncOptions" | "destinationConfig" | "destination" | "eraseStatus" | "environment" | "connectionId" | "folderId"
> & {
syncId: string;
eraseMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@ -2080,7 +2176,16 @@ export type Event =
| DeleteProjectTemplateEvent
| ApplyProjectTemplateEvent
| GetAppConnectionsEvent
| GetAvailableAppConnectionsDetailsEvent
| GetAppConnectionEvent
| CreateAppConnectionEvent
| UpdateAppConnectionEvent
| DeleteAppConnectionEvent;
| DeleteAppConnectionEvent
| GetSecretSyncsEvent
| GetSecretSyncEvent
| CreateSecretSyncEvent
| UpdateSecretSyncEvent
| DeleteSecretSyncEvent
| SyncSecretSyncEvent
| ImportSecretSyncEvent
| EraseSecretSyncEvent;

View File

@ -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 {
Read = "read",
@ -7,6 +15,14 @@ export enum OrgPermissionActions {
Delete = "delete"
}
export enum OrgPermissionAppConnectionActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
Connect = "connect"
}
export enum OrgPermissionAdminConsoleAction {
AccessAllProjects = "access-all-projects"
}
@ -31,6 +47,10 @@ export enum OrgPermissionSubjects {
AppConnections = "app-connections"
}
export type AppConnectionSubjectFields = {
connectionId: string;
};
export type OrgPermissionSet =
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
| [OrgPermissionActions, OrgPermissionSubjects.Role]
@ -47,9 +67,109 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
| [OrgPermissionActions, OrgPermissionSubjects.AppConnections]
| [
OrgPermissionAppConnectionActions,
(
| OrgPermissionSubjects.AppConnections
| (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields)
)
]
| [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 { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
// ws permissions
@ -125,10 +245,16 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates);
can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections);
can(
[
OrgPermissionAppConnectionActions.Create,
OrgPermissionAppConnectionActions.Edit,
OrgPermissionAppConnectionActions.Delete,
OrgPermissionAppConnectionActions.Read,
OrgPermissionAppConnectionActions.Connect
],
OrgPermissionSubjects.AppConnections
);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
@ -160,7 +286,7 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
return rules;
};

View 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));

View File

@ -1,6 +1,10 @@
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 { conditionsMatcher, PermissionConditionOperators } from "@app/lib/casl";
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
@ -60,7 +64,8 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
Cmek = "cmek"
Cmek = "cmek",
SecretSyncs = "secret-syncs"
}
export type SecretSubjectFields = {
@ -140,6 +145,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions, ProjectPermissionSub.SecretSyncs]
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
@ -147,14 +153,6 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
| [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
// if you want to update create a new schema
const SecretConditionV1Schema = z
@ -392,10 +390,15 @@ const GeneralPermissionSchema = [
}),
z.object({
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(
"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(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
})
];
@ -511,7 +514,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshCertificateTemplates
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SecretSyncs
].forEach((el) => {
can(
[
@ -713,6 +717,16 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.Cmek
);
can(
[
ProjectPermissionActions.Read,
ProjectPermissionActions.Edit,
ProjectPermissionActions.Create,
ProjectPermissionActions.Delete
],
ProjectPermissionSub.SecretSyncs
);
return rules;
};
@ -746,6 +760,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs);
return rules;
};

View File

@ -23,6 +23,8 @@ export const KeyStorePrefixes = {
`sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const,
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
`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) =>
`identity-access-token-status:${identityAccessTokenId}`,
ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`
@ -30,6 +32,7 @@ export const KeyStorePrefixes = {
export const KeyStoreTtls = {
SetSyncSecretIntegrationLastRunTimestampInSeconds: 60,
SetSecretSyncLastRunTimestampInSeconds: 60,
AccessTokenStatusUpdateInSeconds: 120
};

View File

@ -1,5 +1,7 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
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 = {
CREATE: {
@ -1636,6 +1638,66 @@ export const AppConnections = {
};
},
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.`,
folderId: `The ID of the project folder to sync secrets from.`,
connectionId: `The ID of the ${
APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]]
} Connection to use for syncing.`,
isEnabled: `Whether secrets should be synced automatically 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.`,
name: `The updated name of the ${destinationName} Sync. Must be slug-friendly.`,
folderId: `The updated project folder ID to sync secrets from.`,
description: `The updated description of the ${destinationName} Sync.`,
isEnabled: `Whether secrets should be synced automatically 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.`
}),
SYNC: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger a sync for.`
}),
IMPORT: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger an import for.`,
shouldOverwrite: `Specify whether newly imported secrets should override existing secrets with matching names in Infisical.`
}),
ERASE: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger an erase for.`
}),
SYNC_OPTIONS: {
PREPEND_PREFIX: "Optionally prepend a prefix to your secrets' keys when syncing.",
APPEND_SUFFIX: "Optionally append a suffix to your secrets' keys when syncing."
},
DESTINATION_CONFIG: {
AWS_PARAMETER_STORE: {
REGION: "The AWS region to sync secrets to.",
PATH: "The Parameter Store path to sync secrets to."
}
}
};

View File

@ -16,3 +16,7 @@ export const prefixWithSlash = (str: string) => {
};
export const startsWithVowel = (str: string) => /^[aeiou]/i.test(str);
export const wrapWithSlashes = (str: string) => {
return `${str.startsWith("/") ? "" : "/"}${str}${str.endsWith("/") ? "" : `/`}`;
};

View File

@ -15,6 +15,12 @@ import {
TIntegrationSyncPayload,
TSyncSecretsDTO
} from "@app/services/secret/secret-types";
import {
TQueueSecretSyncByIdDTO,
TQueueSecretSyncEraseByIdDTO,
TQueueSecretSyncImportByIdDTO,
TQueueSendSecretSyncActionFailedNotificationsDTO
} from "@app/services/secret-sync/secret-sync-types";
export enum QueueName {
SecretRotation = "secret-rotation",
@ -36,7 +42,8 @@ export enum QueueName {
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
ProjectV3Migration = "project-v3-migration",
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 {
@ -61,7 +68,11 @@ export enum QueueJobs {
ProjectV3Migration = "project-v3-migration",
IdentityAccessTokenStatusUpdate = "identity-access-token-status-update",
ServiceTokenStatusUpdate = "service-token-status-update",
ImportSecretsFromExternalSource = "import-secrets-from-external-source"
ImportSecretsFromExternalSource = "import-secrets-from-external-source",
AppConnectionSecretSync = "app-connection-secret-sync",
AppConnectionSecretSyncImport = "app-connection-secret-sync-import",
AppConnectionSecretSyncErase = "app-connection-secret-sync-erase",
AppConnectionSendSecretSyncActionFailedNotifications = "app-connection-send-secret-sync-action-failed-notifications"
}
export type TQueueJobTypes = {
@ -184,6 +195,23 @@ export type TQueueJobTypes = {
};
};
};
[QueueName.AppConnectionSecretSync]:
| {
name: QueueJobs.AppConnectionSecretSync;
payload: TQueueSecretSyncByIdDTO;
}
| {
name: QueueJobs.AppConnectionSecretSyncImport;
payload: TQueueSecretSyncImportByIdDTO;
}
| {
name: QueueJobs.AppConnectionSecretSyncErase;
payload: TQueueSecretSyncEraseByIdDTO;
}
| {
name: QueueJobs.AppConnectionSendSecretSyncActionFailedNotifications;
payload: TQueueSendSecretSyncActionFailedNotificationsDTO;
};
};
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;

View File

@ -195,6 +195,9 @@ import { secretImportDALFactory } from "@app/services/secret-import/secret-impor
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal";
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 { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
@ -317,6 +320,7 @@ export const registerRoutes = async (
const trustedIpDAL = trustedIpDALFactory(db);
const telemetryDAL = telemetryDALFactory(db);
const appConnectionDAL = appConnectionDALFactory(db);
const secretSyncDAL = secretSyncDALFactory(db, folderDAL);
// ee db layer ops
const permissionDAL = permissionDALFactory(db);
@ -821,6 +825,28 @@ export const registerRoutes = async (
kmsService
});
const secretSyncQueue = secretSyncQueueFactory({
queueService,
secretSyncDAL,
folderDAL,
secretImportDAL,
secretV2BridgeDAL,
kmsService,
keyStore,
auditLogService,
smtpService,
projectDAL,
projectMembershipDAL,
projectBotDAL,
secretDAL,
secretBlindIndexDAL,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
secretVersionV2BridgeDAL,
secretVersionTagV2BridgeDAL
});
const secretQueueService = secretQueueFactory({
keyStore,
queueService,
@ -854,7 +880,8 @@ export const registerRoutes = async (
secretApprovalRequestDAL,
projectKeyDAL,
projectUserMembershipRoleDAL,
orgService
orgService,
secretSyncQueue
});
const projectService = projectServiceFactory({
@ -1362,6 +1389,17 @@ export const registerRoutes = async (
licenseService
});
const secretSyncService = secretSyncServiceFactory({
secretSyncDAL,
permissionService,
appConnectionService,
licenseService,
folderDAL,
secretSyncQueue,
projectBotService,
keyStore
});
await superAdminService.initServerCfg();
// setup the communication with license key server
@ -1459,7 +1497,8 @@ export const registerRoutes = async (
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
projectTemplate: projectTemplateService,
totp: totpService,
appConnection: appConnectionService
appConnection: appConnectionService,
secretSync: secretSyncService
});
const cronJobs: CronJob[] = [];

View File

@ -15,7 +15,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
app,
createSchema,
updateSchema,
responseSchema
sanitizedResponseSchema
}: {
app: AppConnection;
server: FastifyZodProvider;
@ -26,7 +26,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
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];
@ -39,7 +39,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
schema: {
description: `List the ${appName} Connections for the current organization.`,
response: {
200: z.object({ appConnections: responseSchema.array() })
200: z.object({ appConnections: sanitizedResponseSchema.array() })
}
},
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({
method: "GET",
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)
}),
response: {
200: z.object({ appConnection: responseSchema })
200: z.object({ appConnection: sanitizedResponseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
@ -114,11 +152,12 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
params: z.object({
connectionName: z
.string()
.min(0, "Connection name required")
.trim()
.min(1, "Connection name required")
.describe(AppConnections.GET_BY_NAME(app).connectionName)
}),
response: {
200: z.object({ appConnection: responseSchema })
200: z.object({ appConnection: sanitizedResponseSchema })
}
},
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.`,
body: createSchema,
response: {
200: z.object({ appConnection: responseSchema })
200: z.object({ appConnection: sanitizedResponseSchema })
}
},
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(
{ name, method, app, credentials, description },
req.permission
)) as TAppConnection;
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
@ -201,7 +240,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
}),
body: updateSchema,
response: {
200: z.object({ appConnection: responseSchema })
200: z.object({ appConnection: sanitizedResponseSchema })
}
},
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)
}),
response: {
200: z.object({ appConnection: responseSchema })
200: z.object({ appConnection: sanitizedResponseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),

View File

@ -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
};

View File

@ -11,7 +11,7 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
registerAppConnectionEndpoints({
app: AppConnection.AWS,
server,
responseSchema: SanitizedAwsConnectionSchema,
sanitizedResponseSchema: SanitizedAwsConnectionSchema,
createSchema: CreateAwsConnectionSchema,
updateSchema: UpdateAwsConnectionSchema
});

View File

@ -11,7 +11,7 @@ export const registerGitHubConnectionRouter = async (server: FastifyZodProvider)
registerAppConnectionEndpoints({
app: AppConnection.GitHub,
server,
responseSchema: SanitizedGitHubConnectionSchema,
sanitizedResponseSchema: SanitizedGitHubConnectionSchema,
createSchema: CreateGitHubConnectionSchema,
updateSchema: UpdateGitHubConnectionSchema
});

View File

@ -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 "./apps";
export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> =
{
[AppConnection.AWS]: registerAwsConnectionRouter,
[AppConnection.GitHub]: registerGitHubConnectionRouter
};

View File

@ -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 { 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 { 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(
async (appConnectionsRouter) => {
await appConnectionsRouter.register(registerAppConnectionRouter);
for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_MAP)) {
await appConnectionsRouter.register(router, { prefix: `/${app}` });
async (appConnectionRouter) => {
// register generic app connection endpoints
await appConnectionRouter.register(registerAppConnectionRouter);
// 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" }
);
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" }
);
};

View File

@ -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
});

View File

@ -0,0 +1,9 @@
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router";
export * from "./secret-sync-router";
export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: FastifyZodProvider) => Promise<void>> = {
[SecretSync.AWSParameterStore]: registerAwsParameterStoreSyncRouter
};

View File

@ -0,0 +1,398 @@
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 } 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;
folderId: string;
connectionId: string;
destinationConfig: I["destinationConfig"];
syncOptions?: I["syncOptions"];
description?: string | null;
}>;
updateSchema: z.ZodType<{
name?: string;
folderId?: string;
destinationConfig?: I["destinationConfig"];
syncOptions?: I["syncOptions"];
description?: string | null;
}>;
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: `/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 syncOptions = req.body.syncOptions ?? {};
const secretSync = (await server.services.secretSync.createSecretSync(
{ ...req.body, destination, syncOptions },
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,
syncOptions
}
}
});
return { secretSync };
}
});
server.route({
method: "PATCH",
url: "/:syncId",
config: {
rateLimit: writeLimit
},
schema: {
description: `Update the specified ${destinationName} Connection.`,
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} Connection.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.DELETE(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.deleteSecretSync(
{ destination, syncId },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.DELETE_SECRET_SYNC,
metadata: {
destination,
syncId
}
}
});
return { secretSync };
}
});
server.route({
method: "POST",
url: "/:syncId/sync",
config: {
rateLimit: writeLimit
},
schema: {
description: `Trigger a sync for the specified ${destinationName} Sync.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.SYNC(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.triggerSecretSyncById(
{
syncId,
destination,
auditLogInfo: req.auditLogInfo
},
req.permission
)) as T;
return { secretSync };
}
});
server.route({
method: "POST",
url: "/:syncId/import",
config: {
rateLimit: writeLimit
},
schema: {
description: `Import secrets from the specified ${destinationName} Sync destination.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.IMPORT(destination).syncId)
}),
querystring: z.object({
shouldOverwrite: z
.enum(["true", "false"])
.optional()
.transform((val) => val === "true")
.describe(SecretSyncs.IMPORT(destination).shouldOverwrite)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const { shouldOverwrite } = req.query;
const secretSync = (await server.services.secretSync.triggerSecretSyncImportById(
{
syncId,
destination,
shouldOverwrite
},
req.permission
)) as T;
return { secretSync };
}
});
server.route({
method: "POST",
url: "/:syncId/erase",
config: {
rateLimit: writeLimit
},
schema: {
description: `Erase synced secrets from the specified ${destinationName} Sync destination.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.ERASE(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.triggerSecretSyncEraseById(
{
syncId,
destination
},
req.permission
)) as T;
return { secretSync };
}
});
};

View File

@ -0,0 +1,80 @@
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";
// union once more available
const SecretSyncSchema = AwsParameterStoreSyncSchema;
// union once more available
const SecretSyncOptionsSchema = AwsParameterStoreSyncListItemSchema;
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 };
}
});
};

View File

@ -2,3 +2,50 @@ export enum AppConnection {
GitHub = "github",
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
}

View File

@ -1,3 +1,4 @@
import { TAppConnections } from "@app/db/schemas/app-connections";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service";
import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types";
@ -64,9 +65,8 @@ export const validateAppConnectionCredentials = async (
): Promise<TAppConnection["credentials"]> => {
const { app } = appConnection;
switch (app) {
case AppConnection.AWS: {
case AppConnection.AWS:
return validateAwsConnectionCredentials(appConnection);
}
case AppConnection.GitHub:
return validateGitHubConnectionCredentials(appConnection);
default:
@ -90,3 +90,17 @@ export const getAppConnectionMethodName = (method: TAppConnection["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;
};

View File

@ -1,13 +1,13 @@
import { ForbiddenError } from "@casl/ability";
import { ForbiddenError, subject } from "@casl/ability";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
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 { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
decryptAppConnectionCredentials,
decryptAppConnection,
encryptAppConnectionCredentials,
getAppConnectionMethodName,
listAppConnectionOptions,
@ -65,7 +65,10 @@ export const appConnectionServiceFactory = ({
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
const appConnections = await appConnectionDAL.find(
app
@ -78,18 +81,7 @@ export const appConnectionServiceFactory = ({
return Promise.all(
appConnections
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map(async ({ encryptedCredentials, ...connection }) => {
const credentials = await decryptAppConnectionCredentials({
encryptedCredentials,
kmsService,
orgId: connection.orgId
});
return {
...connection,
credentials
} as TAppConnection;
})
.map((appConnection) => decryptAppConnection(appConnection, kmsService))
);
};
@ -108,19 +100,15 @@ export const appConnectionServiceFactory = ({
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
if (appConnection.app !== app)
throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` });
return {
...appConnection,
credentials: await decryptAppConnectionCredentials({
encryptedCredentials: appConnection.encryptedCredentials,
orgId: appConnection.orgId,
kmsService
})
} as TAppConnection;
return decryptAppConnection(appConnection, kmsService);
};
const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => {
@ -139,19 +127,15 @@ export const appConnectionServiceFactory = ({
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Read,
OrgPermissionSubjects.AppConnections
);
if (appConnection.app !== app)
throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` });
return {
...appConnection,
credentials: await decryptAppConnectionCredentials({
encryptedCredentials: appConnection.encryptedCredentials,
orgId: appConnection.orgId,
kmsService
})
} as TAppConnection;
return decryptAppConnection(appConnection, kmsService);
};
const createAppConnection = async (
@ -168,7 +152,10 @@ export const appConnectionServiceFactory = ({
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 isConflictingName = Boolean(
@ -216,7 +203,7 @@ export const appConnectionServiceFactory = ({
};
});
return appConnection;
return appConnection as TAppConnection;
};
const updateAppConnection = async (
@ -237,7 +224,10 @@ export const appConnectionServiceFactory = ({
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) => {
if (params.name && appConnection.name !== params.name) {
@ -304,14 +294,7 @@ export const appConnectionServiceFactory = ({
return updatedConnection;
});
return {
...updatedAppConnection,
credentials: await decryptAppConnectionCredentials({
encryptedCredentials: updatedAppConnection.encryptedCredentials,
orgId: updatedAppConnection.orgId,
kmsService
})
} as TAppConnection;
return decryptAppConnection(updatedAppConnection, kmsService);
};
const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
@ -329,23 +312,74 @@ export const appConnectionServiceFactory = ({
appConnection.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionAppConnectionActions.Delete,
OrgPermissionSubjects.AppConnections
);
if (appConnection.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
const deletedAppConnection = await appConnectionDAL.deleteById(connectionId);
try {
const deletedAppConnection = await appConnectionDAL.deleteById(connectionId);
return {
...deletedAppConnection,
credentials: await decryptAppConnectionCredentials({
encryptedCredentials: deletedAppConnection.encryptedCredentials,
orgId: deletedAppConnection.orgId,
kmsService
})
} as TAppConnection;
return await decryptAppConnection(deletedAppConnection, kmsService);
} catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23503") {
throw new BadRequestError({
message:
"Cannot delete App Connection with existing connections. Remove all existing connections and try again."
});
}
throw err;
}
};
const connectAppConnectionById = async (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 })
);
return decryptAppConnection(appConnection, kmsService);
};
const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor) => {
await checkAppServicesAvailability(actor.orgId);
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 {
@ -355,6 +389,8 @@ export const appConnectionServiceFactory = ({
findAppConnectionByName,
createAppConnection,
updateAppConnection,
deleteAppConnection
deleteAppConnection,
connectAppConnectionById,
listAvailableAppConnectionsForUser
};
};

View File

@ -4,7 +4,7 @@ import { randomUUID } from "crypto";
import { getConfig } from "@app/lib/config/env";
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 { 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();
let accessKeyId: string;

View File

@ -75,7 +75,7 @@ export const UpdateAwsConnectionSchema = z
export const AwsConnectionListItemSchema = z.object({
name: z.literal("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.nativeEnum(AwsConnectionMethod).array(),
accessKeyId: z.string().optional()

View File

@ -57,7 +57,7 @@ export const UpdateGitHubConnectionSchema = z
const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) });
export const GitHubAppConnectionSchema = z.intersection(
export const GitHubConnectionSchema = z.intersection(
BaseGitHubConnectionSchema,
z.discriminatedUnion("method", [
z.object({
@ -85,8 +85,8 @@ export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
export const GitHubConnectionListItemSchema = z.object({
name: z.literal("GitHub"),
app: z.literal(AppConnection.GitHub),
// the below is preferable but currently breaks mintlify
// methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]),
// the below is preferable but currently breaks with our zod to json schema parser
// methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]),
methods: z.nativeEnum(GitHubConnectionMethod).array(),
oauthClientId: z.string().optional(),
appClientSlug: z.string().optional()

View File

@ -5,11 +5,11 @@ import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateGitHubConnectionSchema,
GitHubAppConnectionSchema,
GitHubConnectionSchema,
ValidateGitHubConnectionCredentialsSchema
} 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> & {
app: AppConnection.GitHub;

View File

@ -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,
supportsImport: true
};

View File

@ -0,0 +1,198 @@
import AWS, { AWSError } from "aws-sdk";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { TSecretMap, TSecretSyncWithConnection } from "@app/services/secret-sync/secret-sync-types";
import { TAwsParameterStoreSyncWithConnection } 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: TSecretSyncWithConnection) => {
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) {
const secKey = parameter.Name.substring(path.length);
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 = {
sync: async (secretSync: TAwsParameterStoreSyncWithConnection, secrets: 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(secrets)) {
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;
}
await putParameter(ssm, {
Name: `${destinationConfig.path}${key}`,
Type: "SecureString",
Value: value,
Overwrite: true
});
}
const parametersToDelete: AWS.SSM.Parameter[] = [];
for (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
const [key, parameter] = entry;
if (!(key in secrets) || !secrets[key].value) {
parametersToDelete.push(parameter);
}
}
await deleteParametersBatch(ssm, parametersToDelete);
},
import: async (secretSync: TAwsParameterStoreSyncWithConnection): 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 ?? "" }])
);
},
erase: async (secretSync: TAwsParameterStoreSyncWithConnection, secrets: 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 secrets) {
parametersToDelete.push(param);
}
}
await deleteParametersBatch(ssm, parametersToDelete);
}
};

View File

@ -0,0 +1,59 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { wrapWithSlashes } from "@app/lib/fn";
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()
.min(1, "Parameter Store Path Required")
.transform(wrapWithSlashes)
.superRefine((val, ctx) => {
if (!/^\/([\w-]+\/)*[\w-]+\/$/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid Parameter Store Path - must follow "/example/path/" format`
});
}
if (val.length > 2048) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid Parameter Store Path - cannot exceed 2048 characters`
});
}
})
.describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.PATH)
});
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(AppConnection.AWS).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),
supportsImport: z.literal(true)
});

View File

@ -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 TAwsParameterStoreSyncWithConnection = Omit<TAwsParameterStoreSync, "connection"> & {
connection: TAwsConnection;
};

View File

@ -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";

View File

@ -0,0 +1,201 @@
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)
.join(TableName.SecretFolder, `${TableName.SecretSync}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(TableName.AppConnection, `${TableName.SecretSync}.connectionId`, `${TableName.AppConnection}.id`)
.select(selectAllTableCols(TableName.SecretSync))
.select(
// evironment
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("id").withSchema(TableName.Environment).as("envId"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("projectId").withSchema(TableName.Environment),
// 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: { id: envId, name: envName, slug: envSlug },
connection: {
app: connectionApp,
id: connectionId,
name: connectionName,
orgId: connectionOrgId,
encryptedCredentials: connectionEncryptedCredentials,
method: connectionMethod,
description: connectionDescription,
createdAt: connectionCreatedAt,
updatedAt: connectionUpdatedAt,
version: connectionVersion
},
folder: {
id: folder!.id,
path: folder!.path
}
};
};
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] = 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] = 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] = 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] = 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.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, folderRecord[secretSync.folderId]));
} catch (error) {
throw new DatabaseError({ error, name: "Find - Secret Sync" });
}
};
return { ...secretSyncOrm, findById, findOne, find, create, updateById };
};

View File

@ -0,0 +1,3 @@
export enum SecretSync {
AWSParameterStore = "aws-parameter-store"
}

View File

@ -0,0 +1,70 @@
import { BadRequestError } from "@app/lib/errors";
import {
AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
AwsParameterStoreSyncFns
} from "@app/services/secret-sync/aws-parameter-store";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
import {
TSecretMap,
TSecretSyncListItem,
TSecretSyncWithConnection
} from "@app/services/secret-sync/secret-sync-types";
const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION
};
export const listSecretSyncOptions = () => {
return Object.values(SECRET_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
};
const processSyncOptions = (secretSync: TSecretSyncWithConnection, 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;
};
export const SecretSyncFns = {
sync: (secretSync: TSecretSyncWithConnection, unprocessedSecretMap: TSecretMap): Promise<void> => {
const secretMap = processSyncOptions(secretSync, unprocessedSecretMap);
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
return AwsParameterStoreSyncFns.sync(secretSync, secretMap);
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled sync destination for push secrets: ${secretSync.destination}`);
}
},
import: (secretSync: TSecretSyncWithConnection): Promise<TSecretMap> => {
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
return AwsParameterStoreSyncFns.import(secretSync);
default:
throw new BadRequestError({
message: `${SECRET_SYNC_NAME_MAP[secretSync.destination as SecretSync]} Syncs do not support pulling.`
});
}
},
erase: (secretSync: TSecretSyncWithConnection, unprocessedSecretMap: TSecretMap): Promise<void> => {
const secretMap = processSyncOptions(secretSync, unprocessedSecretMap);
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
return AwsParameterStoreSyncFns.erase(secretSync, secretMap);
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled sync destination for purging secrets: ${secretSync.destination}`);
}
}
};

View File

@ -0,0 +1,10 @@
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"
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.AWSParameterStore]: AppConnection.AWS
};

View File

@ -0,0 +1,809 @@
import opentelemetry from "@opentelemetry/api";
import { AxiosError } from "axios";
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 { InternalServerError } from "@app/lib/errors";
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 { 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 } from "@app/services/secret-sync/secret-sync-enums";
import { 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,
TQueueSecretSyncByIdDTO,
TQueueSecretSyncEraseByIdDTO,
TQueueSecretSyncImportByIdDTO,
TQueueSecretSyncsByPathDTO,
TQueueSendSecretSyncActionFailedNotificationsDTO,
TSecretMap,
TSecretSyncDTO,
TSecretSyncEraseDTO,
TSecretSyncImportDTO,
TSecretSyncRaw,
TSecretSyncWithConnection,
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">;
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">;
};
export const secretSyncQueueFactory = ({
queueService,
kmsService,
keyStore,
folderDAL,
secretV2BridgeDAL,
secretImportDAL,
secretSyncDAL,
auditLogService,
projectMembershipDAL,
projectDAL,
smtpService,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
secretVersionV2BridgeDAL,
secretVersionTagV2BridgeDAL
}: TSecretSyncQueueFactoryDep) => {
const appCfg = getConfig();
const integrationMeter = opentelemetry.metrics.getMeter("SecretSyncs");
const syncErrorHistogram = integrationMeter.createHistogram("secret_sync_errors", {
description: "Secret Sync - sync errors",
unit: "1"
});
const importErrorHistogram = integrationMeter.createHistogram("secret_sync_import_errors", {
description: "Secret Sync - import errors",
unit: "1"
});
const eraseErrorHistogram = integrationMeter.createHistogram("secret_sync_erase_errors", {
description: "Secret Sync - erase errors",
unit: "1"
});
const $createManySecretsRawFn = createManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
kmsService,
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL
});
const $updateManySecretsRawFn = updateManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
kmsService,
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL
});
const $getSecrets = async (secretSync: TSecretSyncRaw, includeImports = true) => {
const {
projectId,
folderId,
environment: { slug: environmentSlug },
folder: { path: secretPath }
} = secretSync;
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: environmentSlug,
secretPath,
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 queueSecretSyncById = async (payload: TQueueSecretSyncByIdDTO) =>
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.AppConnectionSecretSync, payload, {
attempts: 5,
delay: 1000,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: true,
removeOnFail: true
});
const queueSecretSyncImportById = async (payload: TQueueSecretSyncImportByIdDTO) =>
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.AppConnectionSecretSyncImport, payload, {
attempts: 5,
delay: 1000,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: true,
removeOnFail: true
});
const queueSecretSyncEraseById = async (payload: TQueueSecretSyncEraseByIdDTO) =>
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.AppConnectionSecretSyncErase, payload, {
attempts: 5,
delay: 1000,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: true,
removeOnFail: true
});
const $queueSendSecretSyncFailedNotifications = async (payload: TQueueSendSecretSyncActionFailedNotificationsDTO) => {
if (!appCfg.isSmtpConfigured) return;
await queueService.queue(
QueueName.AppConnectionSecretSync,
QueueJobs.AppConnectionSendSecretSyncActionFailedNotifications,
payload,
{
jobId: `secret-sync-${payload.secretSync.id}-failed-notifications`,
attempts: 5,
delay: 1000 * 60,
backoff: {
type: "exponential",
delay: 3000
},
removeOnFail: true,
removeOnComplete: true
}
);
};
const $syncSecrets = async (job: TSecretSyncDTO) => {
const {
data: { syncId, auditLogInfo }
} = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
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;
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
try {
const {
connection: { orgId, encryptedCredentials }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
});
const secretMap = await $getSecrets(secretSync);
await SecretSyncFns.sync(
{
...secretSync,
connection: {
...secretSync.connection,
credentials
}
} as TSecretSyncWithConnection,
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) {
syncErrorHistogram.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 =
// eslint-disable-next-line no-nested-ternary
(err instanceof AxiosError
? err?.response?.data
? JSON.stringify(err?.response?.data)
: err?.message
: (err as Error)?.message) || "An unknown error occurred.";
// re-throw so job fails
throw err;
} finally {
const ranAt = new Date();
const syncStatus = isSynced ? SecretSyncStatus.Success : SecretSyncStatus.Failed;
await auditLogService.createAuditLog({
projectId: secretSync.projectId,
...(auditLogInfo ?? {
actor: {
type: ActorType.PLATFORM,
metadata: {}
}
}),
event: {
type: EventType.SYNC_SECRET_SYNC,
metadata: {
syncId: secretSync.id,
syncOptions: secretSync.syncOptions,
environment: secretSync.environment,
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.Sync
});
}
}
}
logger.info("SecretSync Sync Job with ID %s Completed", job.id);
};
const $importSecrets = async (job: TSecretSyncImportDTO) => {
const {
data: { syncId, auditLogInfo, shouldOverwrite }
} = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
logger.info(
`SecretSync Import [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
let isImported = false;
let importMessage: string | null = null;
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
try {
const {
connection: { orgId, encryptedCredentials },
projectId,
environment
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
});
const importedSecrets = await SecretSyncFns.import({
...secretSync,
connection: {
...secretSync.connection,
credentials
}
} as TSecretSyncWithConnection);
if (Object.keys(importedSecrets).length) {
const secretMap = await $getSecrets(secretSync, false);
const secretsToCreate: Parameters<typeof $createManySecretsRawFn>[0]["secrets"] = [];
const secretsToUpdate: Parameters<typeof $updateManySecretsRawFn>[0]["secrets"] = [];
Object.entries(importedSecrets).forEach(([key, { value }]) => {
const secret = {
secretName: key,
secretValue: value,
type: SecretType.Shared,
secretComment: ""
};
if (Object.hasOwn(secretMap, key)) {
secretsToUpdate.push(secret);
} else {
secretsToCreate.push(secret);
}
});
if (secretsToCreate.length) {
await $createManySecretsRawFn({
projectId,
path: secretSync.folder.path,
environment: environment.slug,
secrets: secretsToCreate
});
}
if (shouldOverwrite && secretsToUpdate.length) {
await $updateManySecretsRawFn({
projectId,
path: secretSync.folder.path,
environment: environment.slug,
secrets: secretsToUpdate
});
}
}
isImported = 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) {
importErrorHistogram.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 =
// eslint-disable-next-line no-nested-ternary
(err instanceof AxiosError
? err?.response?.data
? JSON.stringify(err?.response?.data)
: err?.message
: (err as Error)?.message) || "An unknown error occurred.";
// re-throw so job fails
throw err;
} finally {
const ranAt = new Date();
const importStatus = isImported ? SecretSyncStatus.Success : SecretSyncStatus.Failed;
await auditLogService.createAuditLog({
projectId: secretSync.projectId,
...(auditLogInfo ?? {
actor: {
type: ActorType.PLATFORM,
metadata: {}
}
}),
event: {
type: EventType.IMPORT_SECRET_SYNC,
metadata: {
syncId: secretSync.id,
syncOptions: secretSync.syncOptions,
environment: secretSync.environment,
destination: secretSync.destination,
destinationConfig: secretSync.destinationConfig,
folderId: secretSync.folderId,
connectionId: secretSync.connectionId,
jobRanAt: ranAt,
jobId: job.id!,
importStatus,
importMessage
}
}
});
if (isImported || isFinalAttempt) {
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
importStatus,
lastImportJobId: job.id,
lastImportMessage: importMessage,
lastImportedAt: isImported ? ranAt : undefined
});
if (!isImported) {
await $queueSendSecretSyncFailedNotifications({
secretSync: updatedSecretSync,
action: SecretSyncAction.Import
});
}
}
}
logger.info("SecretSync Import Job with ID %s Completed", job.id);
};
const $eraseSecrets = async (job: TSecretSyncEraseDTO) => {
const {
data: { syncId, auditLogInfo }
} = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
logger.info(
`SecretSync Erase [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
let isErased = false;
let eraseMessage: 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 $getSecrets(secretSync);
await SecretSyncFns.erase(
{
...secretSync,
connection: {
...secretSync.connection,
credentials
}
} as TSecretSyncWithConnection,
secretMap
);
isErased = true;
} catch (err) {
logger.error(
err,
`SecretSync Erase Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
eraseErrorHistogram.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
});
}
eraseMessage =
// eslint-disable-next-line no-nested-ternary
(err instanceof AxiosError
? err?.response?.data
? JSON.stringify(err?.response?.data)
: err?.message
: (err as Error)?.message) || "An unknown error occurred.";
// re-throw so job fails
throw err;
} finally {
const ranAt = new Date();
const eraseStatus = isErased ? SecretSyncStatus.Success : SecretSyncStatus.Failed;
await auditLogService.createAuditLog({
projectId: secretSync.projectId,
...(auditLogInfo ?? {
actor: {
type: ActorType.PLATFORM,
metadata: {}
}
}),
event: {
type: EventType.ERASE_SECRET_SYNC,
metadata: {
syncId: secretSync.id,
syncOptions: secretSync.syncOptions,
environment: secretSync.environment,
destination: secretSync.destination,
destinationConfig: secretSync.destinationConfig,
folderId: secretSync.folderId,
connectionId: secretSync.connectionId,
jobRanAt: ranAt,
jobId: job.id!,
eraseStatus,
eraseMessage
}
}
});
if (isErased || isFinalAttempt) {
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
eraseStatus,
lastEraseJobId: job.id,
lastEraseMessage: eraseMessage,
lastErasedAt: isErased ? ranAt : undefined
});
if (!isErased) {
await $queueSendSecretSyncFailedNotifications({
secretSync: updatedSecretSync,
action: SecretSyncAction.Erase
});
}
}
}
logger.info("SecretSync Erase Job with ID %s Completed", job.id);
};
const $sendSecretSyncFailedNotifications = async (job: TSendSecretSyncFailedNotificationsJobDTO) => {
const {
data: { secretSync, auditLogInfo, action }
} = job;
const { projectId, destination, name, folder, lastSyncMessage, lastEraseMessage, 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 subject: string;
let failureMessage: string | null | undefined;
let content: string;
switch (action) {
case SecretSyncAction.Import:
subject = "Import";
failureMessage = lastImportMessage;
content = `Your ${syncDestination} Sync named "${name}" failed while attempting to import secrets.`;
break;
case SecretSyncAction.Erase:
subject = "Erase";
failureMessage = lastEraseMessage;
content = `Your ${syncDestination} Sync named "${name}" failed while attempting to erase secrets.`;
break;
case SecretSyncAction.Sync:
default:
subject = `Sync`;
failureMessage = lastSyncMessage;
content = `Your ${syncDestination} Sync named "${name}" failed to sync.`;
break;
}
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretSyncFailed,
subjectLine: `Secret Sync Failed to ${subject} Secrets`,
substitutions: {
syncName: name,
syncDestination,
content,
failureMessage,
secretPath: folder.path,
environment: environment.name,
projectName: project.name,
// TODO (scott): verify this is still the URL after bare react change
syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}`
}
});
};
const queueSecretSyncsByPath = 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, isEnabled: true });
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncById({ syncId: secretSync.id })));
};
queueService.start(QueueName.AppConnectionSecretSync, async (job) => {
if (job.name === QueueJobs.AppConnectionSendSecretSyncActionFailedNotifications) {
await $sendSecretSyncFailedNotifications(job as TSendSecretSyncFailedNotificationsJobDTO);
return;
}
const { syncId } = job.data as
| TQueueSecretSyncByIdDTO
| TQueueSecretSyncImportByIdDTO
| TQueueSecretSyncEraseByIdDTO;
const lock = await keyStore.acquireLock([KeyStorePrefixes.SecretSyncLock(syncId)], 5 * 60 * 1000);
try {
switch (job.name) {
case QueueJobs.AppConnectionSecretSync:
await $syncSecrets(job as TSecretSyncDTO);
break;
case QueueJobs.AppConnectionSecretSyncImport:
await $importSecrets(job as TSecretSyncImportDTO);
break;
case QueueJobs.AppConnectionSecretSyncErase:
await $eraseSecrets(job as TSecretSyncEraseDTO);
break;
default:
throw new InternalServerError({
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
message: `Unhandled Secret Sync Job ${job.name}`
});
}
} finally {
await lock.release();
}
});
return {
queueSecretSyncById,
queueSecretSyncImportById,
queueSecretSyncEraseById,
queueSecretSyncsByPath
};
};

View File

@ -0,0 +1,65 @@
import { z } from "zod";
import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs";
import { SecretSyncs } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
const SyncOptionsSchema = z.object({
prependPrefix: z
.string()
.trim()
.transform((str) => str.toUpperCase())
.optional()
.describe(SecretSyncs.SYNC_OPTIONS.PREPEND_PREFIX),
appendSuffix: z
.string()
.trim()
.transform((str) => str.toUpperCase())
.optional()
.describe(SecretSyncs.SYNC_OPTIONS.APPEND_SUFFIX)
});
export const BaseSecretSyncSchema = (app: AppConnection) =>
SecretSyncsSchema.omit({
destination: true,
destinationConfig: true,
syncOptions: true
}).extend({
syncOptions: SyncOptionsSchema,
// join properties
projectId: z.string(),
connection: z.object({ app: z.literal(app), name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }),
folder: z.object({ id: z.string(), path: z.string() })
});
export const GenericCreateSecretSyncFieldsSchema = (sync: SecretSync) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(sync).name),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(SecretSyncs.CREATE(sync).description),
connectionId: z.string().uuid().describe(SecretSyncs.CREATE(sync).connectionId),
folderId: z.string().uuid().describe(SecretSyncs.CREATE(sync).folderId),
isEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(sync).isEnabled),
syncOptions: SyncOptionsSchema.optional().default({}).describe(SecretSyncs.CREATE(sync).syncOptions)
});
export const GenericUpdateSecretSyncFieldsSchema = (sync: SecretSync) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(sync).name).optional(),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(SecretSyncs.UPDATE(sync).description),
folderId: z.string().uuid().optional().describe(SecretSyncs.UPDATE(sync).folderId),
isEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(sync).isEnabled),
syncOptions: SyncOptionsSchema.optional().describe(SecretSyncs.UPDATE(sync).syncOptions)
});

View File

@ -0,0 +1,472 @@
import { ForbiddenError } from "@casl/ability";
import { ProjectType } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { startsWithVowel } from "@app/lib/fn";
import { OrgServiceActor } from "@app/lib/types";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
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 { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
import {
TCreateSecretSyncDTO,
TDeleteSecretSyncDTO,
TFindSecretSyncByIdDTO,
TFindSecretSyncByNameDTO,
TListSecretSyncsByProjectId,
TSecretSync,
TTriggerSecretSyncByIdDTO,
TTriggerSecretSyncEraseByIdDTO,
TTriggerSecretSyncImportByIdDTO,
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">;
keyStore: Pick<TKeyStoreFactory, "getItem">;
secretSyncQueue: Pick<
TSecretSyncQueueFactory,
"queueSecretSyncById" | "queueSecretSyncImportById" | "queueSecretSyncEraseById"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; // TODO: remove once launched
};
export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFactory>;
export const secretSyncServiceFactory = ({
secretSyncDAL,
folderDAL,
licenseService,
permissionService,
appConnectionService,
projectBotService,
secretSyncQueue,
keyStore
}: TSecretSyncServiceFactoryDep) => {
// secret syncs are disabled for public until launch
const checkSecretSyncAvailability = async (orgId: string) => {
const subscription = await licenseService.getPlan(orgId);
if (!subscription.appConnections) throw new BadRequestError({ message: "Secret Syncs are not available yet." });
};
const listSecretSyncsByProjectId = async (
{ projectId, destination }: TListSecretSyncsByProjectId,
actor: OrgServiceActor
) => {
await checkSecretSyncAvailability(actor.orgId);
const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs);
const folders = await folderDAL.findByProjectId(projectId);
const secretSyncs = await secretSyncDAL.find({
...(destination && { destination }),
$in: {
folderId: folders.map((folder) => folder.id)
}
});
return secretSyncs as TSecretSync[];
};
const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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
) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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 (params: TCreateSecretSyncDTO, actor: OrgServiceActor) => {
await checkSecretSyncAvailability(actor.orgId);
const folder = await folderDAL.findById(params.folderId);
if (!folder) throw new BadRequestError({ message: `Could not find Folder with ID "${params.folderId}"` });
const { permission: projectPermission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
folder.projectId,
actor.authMethod,
actor.orgId
);
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(folder.projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({ message: "Project version does not support Secret Syncs" });
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(projectPermission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.SecretSyncs
);
const appConnection = await appConnectionService.connectAppConnectionById(params.connectionId, actor);
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
if (appConnection.app !== destinationApp) {
const appName = APP_CONNECTION_NAME_MAP[appConnection.app];
throw new BadRequestError({
message: `Invalid App Connection - Cannot sync to ${SECRET_SYNC_NAME_MAP[params.destination]} using ${
startsWithVowel(appName) ? "an" : "a"
} ${appName} Connection`
});
}
const projectFolders = await folderDAL.findByProjectId(folder.projectId);
const secretSync = await secretSyncDAL.transaction(async (tx) => {
const isConflictingName = Boolean(
(
await secretSyncDAL.find(
{
name: params.name,
$in: {
folderId: projectFolders.map((f) => f.id)
}
},
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(params);
return sync;
});
if (secretSync.isEnabled) await secretSyncQueue.queueSecretSyncById({ syncId: secretSync.id });
return secretSync as TSecretSync;
};
const updateSecretSync = async ({ destination, syncId, ...params }: TUpdateSecretSyncDTO, actor: OrgServiceActor) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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) => {
if (params.folderId) {
const newFolder = await folderDAL.findById(params.folderId);
if (!newFolder) throw new BadRequestError({ message: `Could not find folder with ID "${params.folderId}"` });
// TODO (scott): I don't think there's a reason we can't allow moving syncs across projects
// but not supporting this initially
if (newFolder.projectId !== secretSync.projectId)
throw new BadRequestError({
message: `Cannot move Secret Sync to different project`
});
}
if (params.name && secretSync.name !== params.name) {
const projectFolders = await folderDAL.findByProjectId(secretSync.projectId);
const isConflictingName = Boolean(
(
await secretSyncDAL.find(
{
name: params.name,
$in: {
folderId: projectFolders.map((f) => f.id)
}
},
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 updatedSync = await secretSyncDAL.updateById(syncId, params);
return updatedSync;
});
if (updatedSecretSync.isEnabled) await secretSyncQueue.queueSecretSyncById({ syncId: secretSync.id });
return updatedSecretSync as TSecretSync;
};
const deleteSecretSync = async ({ destination, syncId }: TDeleteSecretSyncDTO, actor: OrgServiceActor) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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]}`
});
await secretSyncDAL.deleteById(syncId);
return secretSync as TSecretSync;
};
const triggerSecretSyncById = async (
{ syncId, destination, ...params }: TTriggerSecretSyncByIdDTO,
actor: OrgServiceActor
) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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]}`
});
await secretSyncQueue.queueSecretSyncById({ syncId, ...params });
return secretSync as TSecretSync;
};
const triggerSecretSyncImportById = async (
{ syncId, destination, ...params }: TTriggerSecretSyncImportByIdDTO,
actor: OrgServiceActor
) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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]}`
});
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.queueSecretSyncImportById({ syncId, ...params });
return secretSync as TSecretSync;
};
const triggerSecretSyncEraseById = async (
{ syncId, destination, ...params }: TTriggerSecretSyncEraseByIdDTO,
actor: OrgServiceActor
) => {
await checkSecretSyncAvailability(actor.orgId);
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, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission(
actor.type,
actor.id,
secretSync.projectId,
actor.authMethod,
actor.orgId
);
ForbidOnInvalidProjectType(ProjectType.SecretManager);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.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]}`
});
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.queueSecretSyncEraseById({ syncId, ...params });
return secretSync as TSecretSync;
};
return {
listSecretSyncOptions,
listSecretSyncsByProjectId,
findSecretSyncById,
findSecretSyncByName,
createSecretSync,
updateSecretSync,
deleteSecretSync,
triggerSecretSyncById,
triggerSecretSyncImportById,
triggerSecretSyncEraseById
};
};

View File

@ -0,0 +1,131 @@
import { Job } from "bullmq";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue";
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
TAwsParameterStoreSync,
TAwsParameterStoreSyncInput,
TAwsParameterStoreSyncListItem,
TAwsParameterStoreSyncWithConnection
} from "./aws-parameter-store";
export type TSecretSync = TAwsParameterStoreSync;
export type TSecretSyncWithConnection = TAwsParameterStoreSyncWithConnection;
export type TSecretSyncInput = TAwsParameterStoreSyncInput;
export type TSecretSyncListItem = TAwsParameterStoreSyncListItem;
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" | "folderId" | "name" | "connectionId"
> & { destination: SecretSync };
export type TUpdateSecretSyncDTO = Partial<Omit<TCreateSecretSyncDTO, "connectionId">> & {
syncId: string;
destination: SecretSync;
};
export type TDeleteSecretSyncDTO = {
destination: SecretSync;
syncId: string;
};
type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">;
export enum SecretSyncStatus {
Pending = "pending",
Success = "success",
Failed = "failed"
}
export enum SecretSyncAction {
Sync = "sync",
Import = "import",
Erase = "erase"
}
export type TSecretSyncRaw = NonNullable<Awaited<ReturnType<TSecretSyncDALFactory["findById"]>>>;
export type TQueueSecretSyncsByPathDTO = {
secretPath: string;
environmentSlug: string;
projectId: string;
};
export type TQueueSecretSyncByIdDTO = {
syncId: string;
auditLogInfo?: AuditLogInfo;
};
export type TTriggerSecretSyncByIdDTO = {
destination: SecretSync;
} & TQueueSecretSyncByIdDTO;
export type TQueueSecretSyncImportByIdDTO = {
syncId: string;
shouldOverwrite: boolean;
auditLogInfo?: AuditLogInfo;
};
export type TTriggerSecretSyncImportByIdDTO = {
destination: SecretSync;
} & TQueueSecretSyncImportByIdDTO;
export type TQueueSecretSyncEraseByIdDTO = {
syncId: string;
auditLogInfo?: AuditLogInfo;
};
export type TTriggerSecretSyncEraseByIdDTO = {
destination: SecretSync;
} & TQueueSecretSyncEraseByIdDTO;
export type TQueueSendSecretSyncActionFailedNotificationsDTO = {
secretSync: TSecretSyncRaw;
auditLogInfo?: AuditLogInfo;
action: SecretSyncAction;
};
export type TSecretSyncDTO = Job<TQueueSecretSyncByIdDTO, void, QueueJobs.AppConnectionSecretSync>;
export type TSecretSyncImportDTO = Job<TQueueSecretSyncImportByIdDTO, void, QueueJobs.AppConnectionSecretSync>;
export type TSecretSyncEraseDTO = Job<TQueueSecretSyncEraseByIdDTO, void, QueueJobs.AppConnectionSecretSync>;
export type TSendSecretSyncFailedNotificationsJobDTO = Job<
TQueueSendSecretSyncActionFailedNotificationsDTO,
void,
QueueJobs.AppConnectionSendSecretSyncActionFailedNotifications
>;
export type TSecretMap = Record<
string,
{ value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined }
>;
export type TSecretSyncGetSecrets = {
projectId: string;
folderId: string;
secretPath: string;
environmentSlug: string;
includeImports?: boolean;
};

View File

@ -29,6 +29,7 @@ import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@a
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 { TSecretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-queue";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { ActorType } from "../auth/auth-type";
@ -104,6 +105,7 @@ type TSecretQueueFactoryDep = {
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsByPath">;
};
export type TGetSecrets = {
@ -157,7 +159,8 @@ export const secretQueueFactory = ({
auditLogService,
orgService,
projectUserMembershipRoleDAL,
projectKeyDAL
projectKeyDAL,
secretSyncQueue
}: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@ -619,6 +622,9 @@ export const secretQueueFactory = ({
}
}
);
await secretSyncQueue.queueSecretSyncsByPath({ projectId, environmentSlug: environment, secretPath });
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
if (!excludeReplication) {
await replicateSecrets({

View File

@ -35,6 +35,7 @@ export enum SmtpTemplates {
ScimUserProvisioned = "scimUserProvisioned.handlebars",
PkiExpirationAlert = "pkiExpirationAlert.handlebars",
IntegrationSyncFailed = "integrationSyncFailed.handlebars",
SecretSyncFailed = "secretSyncFailed.handlebars",
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
ExternalImportFailed = "externalImportFailed.handlebars",
ExternalImportStarted = "externalImportStarted.handlebars"

View File

@ -0,0 +1,35 @@
<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>
<p><strong>Environment</strong>: {{environment}}</p>
<p><strong>Secret Path</strong>: {{secretPath}}</p>
</div>
{{#if failureMessage}}
<p><b>Reason: </b>{{failureMessage}}</p>
{{/if}}
{{emailFooter}}
</body>
</html>

View File

@ -0,0 +1,4 @@
---
title: "Available"
openapi: "GET /api/v1/app-connections/aws/available"
---

View File

@ -0,0 +1,4 @@
---
title: "Available"
openapi: "GET /api/v1/app-connections/github/available"
---

View File

@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/secret-syncs/aws-parameter-store"
---

View File

@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/secret-syncs/aws-parameter-store/{syncId}"
---

View File

@ -0,0 +1,4 @@
---
title: "Erase Secrets"
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/erase"
---

View File

@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/secret-syncs/aws-parameter-store/{syncId}"
---

View File

@ -0,0 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/secret-syncs/aws-parameter-store/name/{syncName}"
---

View File

@ -0,0 +1,4 @@
---
title: "Import Secrets"
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/import"
---

View File

@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/secret-syncs/aws-parameter-store"
---

View File

@ -0,0 +1,4 @@
---
title: "Sync Secrets"
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/sync"
---

View File

@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/secret-syncs/aws-parameter-store/{syncId}"
---

View File

@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/secret-syncs"
---

View File

@ -0,0 +1,4 @@
---
title: "Options"
openapi: "GET /api/v1/secret-syncs/options"
---

View File

@ -759,6 +759,55 @@
"api-reference/endpoints/identity-specific-privilege/list"
]
},
{
"group": "App Connections",
"pages": [
"api-reference/endpoints/app-connections/list",
"api-reference/endpoints/app-connections/options",
{ "group": "AWS",
"pages": [
"api-reference/endpoints/app-connections/aws/list",
"api-reference/endpoints/app-connections/aws/available",
"api-reference/endpoints/app-connections/aws/get-by-id",
"api-reference/endpoints/app-connections/aws/get-by-name",
"api-reference/endpoints/app-connections/aws/create",
"api-reference/endpoints/app-connections/aws/update",
"api-reference/endpoints/app-connections/aws/delete"
]
},
{ "group": "GitHub",
"pages": [
"api-reference/endpoints/app-connections/github/list",
"api-reference/endpoints/app-connections/github/available",
"api-reference/endpoints/app-connections/github/get-by-id",
"api-reference/endpoints/app-connections/github/get-by-name",
"api-reference/endpoints/app-connections/github/create",
"api-reference/endpoints/app-connections/github/update",
"api-reference/endpoints/app-connections/github/delete"
]
}
]
},
{
"group": "Secret Syncs",
"pages": [
"api-reference/endpoints/secret-syncs/list",
"api-reference/endpoints/secret-syncs/options",
{ "group": "AWS Parameter Store",
"pages": [
"api-reference/endpoints/secret-syncs/aws-parameter-store/list",
"api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-id",
"api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-name",
"api-reference/endpoints/secret-syncs/aws-parameter-store/create",
"api-reference/endpoints/secret-syncs/aws-parameter-store/update",
"api-reference/endpoints/secret-syncs/aws-parameter-store/delete",
"api-reference/endpoints/secret-syncs/aws-parameter-store/sync",
"api-reference/endpoints/secret-syncs/aws-parameter-store/import",
"api-reference/endpoints/secret-syncs/aws-parameter-store/erase"
]
}
]
},
{
"group": "Integrations",
"pages": [