Compare commits

..

65 Commits

Author SHA1 Message Date
27af943ee1 Update integration-sync-secret.ts 2025-01-27 23:18:46 +01:00
9b772ad55a Update VercelConfigurePage.tsx 2025-01-27 23:11:57 +01:00
94a1fc2809 chore: cleanup 2025-01-27 23:11:14 +01:00
10c10642a1 feat(integrations/vercel): custom environments support 2025-01-27 23:08:47 +01:00
a93bfa69c9 Merge pull request #3042 from Infisical/daniel/fix-approvals-for-personal-secrets
fix: approvals triggering for personal secrets
2025-01-25 04:50:19 +01:00
08a0550cd7 fix: correct dependency arra 2025-01-24 19:21:33 -08:00
d7503573b1 Merge pull request #3041 from Infisical/daniel/remove-caching-from-docs
docs: update node guid eand remove cache references
2025-01-25 04:15:53 +01:00
b5a89edeed Update node.mdx 2025-01-25 03:59:06 +01:00
860eaae4c8 fix: approvals triggering for personal secrets 2025-01-25 03:44:43 +01:00
c7a4b6c4e9 docs: update node guid eand remove cache references 2025-01-25 03:12:36 +01:00
c12c6dcc6e Merge pull request #2987 from Infisical/daniel/k8s-multi-managed-secrets
feat(k8-operator/infisicalsecret-crd): multiple secret references
2025-01-25 02:59:07 +01:00
8741414cfa Update routeTree.gen.ts 2025-01-24 18:28:48 +01:00
b8d29793ec fix: rename managedSecretReferneces to managedKubeSecretReferences 2025-01-24 18:26:56 +01:00
92013dbfbc fix: routes 2025-01-24 18:26:34 +01:00
c5319588fe chore: fix routes geneartion 2025-01-24 18:26:23 +01:00
9efb8eaf78 Update infisical-secret-crd.mdx 2025-01-24 18:24:26 +01:00
dfc973c7f7 chore(k8-operator): update helm 2025-01-24 18:24:26 +01:00
3013d1977c docs(k8-operator): updated infisicalsecret crd docs 2025-01-24 18:24:26 +01:00
f358e8942d feat(k8-operator): multiple managed secrets 2025-01-24 18:24:26 +01:00
c3970d1ea2 Merge pull request #3038 from isaiahmartin847/typo-fix/Role-based-Access-Controls
Fixed the typo in the Role-based Access Controls docs.
2025-01-24 01:30:34 -05:00
2dc00a638a fixed the typo in the /access-controls/role-based-access-controls page in the docs. 2025-01-23 23:15:40 -07:00
bab9c1f454 Merge pull request #3024 from Infisical/team-city-integration-fix
Fix: UI Fix for Team City Integrations Create Page
2025-01-23 18:14:32 +01:00
2bd4770fb4 Merge pull request #3035 from akhilmhdh/fix/env-ui
feat: updated ui validation for env to 64 like api
2025-01-23 16:32:04 +05:30
=
31905fab6e feat: updated ui validation for env to 64 like api 2025-01-23 16:26:13 +05:30
784acf16d0 Merge pull request #3032 from Infisical/correct-app-connections-docs
Improvements: Minor Secret Sync improvements and Correct App Connections Env Vars and Move Sync/Connections to Groups in Docs
2025-01-23 03:29:33 -05:00
114b89c952 Merge pull request #3033 from Infisical/daniel/update-python-docs
docs(guides): updated python guide
2025-01-23 03:28:11 -05:00
81420198cb fix: display aws connection credentials error and sync status on details page 2025-01-22 21:00:01 -08:00
b949708f45 docs(sso): fixed azure attributes typo 2025-01-23 05:20:44 +01:00
2a6b6b03b9 docs(guides): updated python guide 2025-01-23 05:20:26 +01:00
0ff18e277f docs: redact info in image 2025-01-22 20:02:03 -08:00
e093f70301 docs: add new aws connection images 2025-01-22 19:58:24 -08:00
8e2ff18f35 docs: improve aws connection docs 2025-01-22 19:58:06 -08:00
3fbfecf7a9 docs: correct aws env vars in aws connection self-hosted docs 2025-01-22 18:46:36 -08:00
9087def21c docs: correct github connection env vars and move connections and syncs to group 2025-01-22 18:40:24 -08:00
89c6ab591a Merge pull request #3031 from Infisical/project-list-pagination-fix
Fix: Project List/Grid Pagination Behavior
2025-01-22 16:27:59 -08:00
235a33a01c Update deployment-pipeline.yml 2025-01-22 18:42:05 -05:00
dd6c217dc8 fix: include page/page size in dependencies array on filtered projects 2025-01-22 14:18:38 -08:00
78b1b5583a Merge pull request #2998 from Infisical/secret-syncs-feature
Feature: Secret Syncs
2025-01-22 12:00:57 -08:00
8f2a504fd0 improvements: address feedback 2025-01-22 10:50:24 -08:00
1d5b629d8f Merge pull request #3006 from akhilmhdh/feat/region-flag
Added region flag for eu in cli
2025-01-22 13:21:14 -05:00
14f895cae2 Merge pull request #3026 from Infisical/readme-ssh
Add Infisical SSH to README
2025-01-22 12:56:21 -05:00
=
b7be6bd1d9 feat: removed region flag in description 2025-01-22 14:41:24 +05:30
58a97852f6 Merge pull request #3027 from akhilmhdh/fix/pro-trail-btn
fix: resolved pro trial button issue in sidebar
2025-01-22 01:58:32 -05:00
=
980aa9eaae fix: resolved pro trial button issue in sidebar 2025-01-22 12:25:54 +05:30
=
a35d1aa72b feat: removed root flag and added description for domain 2025-01-22 12:18:58 +05:30
c92c160709 chore: move migration to latest 2025-01-21 21:51:35 -08:00
71ca7a82db Merge pull request #3022 from Infisical/vercel-project-help-text
Improvement: Vercel Integration Project Permission Helper Text
2025-01-21 21:43:53 -08:00
6f799b478d chore: remove unused component 2025-01-21 21:34:41 -08:00
a89e6b6e58 chore: resolve merge conflicts 2025-01-21 21:30:18 -08:00
99ca9e04f8 improvements: final adjustments/improvements 2025-01-21 20:21:29 -08:00
586dbd79b0 fix: fix team city integrations create page 2025-01-21 18:37:01 -08:00
6cdc71b9b1 Merge pull request #3023 from Infisical/fix-org-sidebar-check
Fix: Correct Display Org Sidebar Check
2025-01-22 03:02:43 +01:00
f88d6a183f fix: correct display org sidebar check 2025-01-21 17:46:14 -08:00
fa82d4953e improvement: adjust casing 2025-01-21 16:05:11 -08:00
12d9fe9ffd improvement: add helper text to point vercel users to access permissions if they dont see their project listed 2025-01-21 16:02:24 -08:00
3c1fc024c2 improvements: address feedback 2025-01-20 22:17:20 -08:00
=
d627ecf05d feat: added region flag for eu in cli 2025-01-18 17:05:29 +05:30
6341b7e989 improvement: update intial sync logic to account for replica reads 2025-01-16 09:48:21 -08:00
bc32d6cbbf chore: revert mint openapi url 2025-01-15 22:42:09 -08:00
0cf3115830 chore: remove needless comment 2025-01-15 22:39:54 -08:00
65f2e626ae chore: optimize import path, add comment context and removed commented out code 2025-01-15 22:37:20 -08:00
8b3e3152a4 chore: remove outdated comment 2025-01-15 22:30:27 -08:00
661b31f762 chore: remove concurrency from testing 2025-01-15 22:24:47 -08:00
e78ad1147b fix: various ui and github sync fixes 2025-01-15 22:20:33 -08:00
473efa91f0 feature: secret sync base + aws parameter store & github 2025-01-15 21:52:50 -08:00
256 changed files with 11599 additions and 1032 deletions

View File

@ -7,7 +7,7 @@ permissions:
concurrency:
group: "infisical-core-deployment"
cancel-in-progress: false
cancel-in-progress: true
jobs:
infisical-tests:

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

@ -372,6 +372,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,
@ -900,5 +901,6 @@ declare module "knex/types/tables" {
TAppConnectionsInsert,
TAppConnectionsUpdate
>;
[TableName.SecretSync]: KnexOriginal.CompositeTableType<TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate>;
}
}

View File

@ -0,0 +1,50 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.SecretSync))) {
await knex.schema.createTable(TableName.SecretSync, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name", 32).notNullable();
t.string("description");
t.string("destination").notNullable();
t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true);
t.integer("version").defaultTo(1).notNullable();
t.jsonb("destinationConfig").notNullable();
t.jsonb("syncOptions").notNullable();
// we're including projectId in addition to folder ID because we allow folderId to be null (if the folder
// is deleted), to preserve sync configuration
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("folderId");
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("SET NULL");
t.uuid("connectionId").notNullable();
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
t.timestamps(true, true, true);
// sync secrets to destination
t.string("syncStatus");
t.string("lastSyncJobId");
t.string("lastSyncMessage");
t.datetime("lastSyncedAt");
// import secrets from destination
t.string("importStatus");
t.string("lastImportJobId");
t.string("lastImportMessage");
t.datetime("lastImportedAt");
// remove secrets from destination
t.string("removeStatus");
t.string("lastRemoveJobId");
t.string("lastRemoveMessage");
t.datetime("lastRemovedAt");
});
await createOnUpdateTrigger(knex, TableName.SecretSync);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretSync);
await dropOnUpdateTrigger(knex, TableName.SecretSync);
}

View File

@ -131,7 +131,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,40 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretSyncsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable().optional(),
destination: z.string(),
isAutoSyncEnabled: z.boolean().default(true),
version: z.number().default(1),
destinationConfig: z.unknown(),
syncOptions: z.unknown(),
projectId: z.string(),
folderId: z.string().uuid().nullable().optional(),
connectionId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
syncStatus: z.string().nullable().optional(),
lastSyncJobId: z.string().nullable().optional(),
lastSyncMessage: z.string().nullable().optional(),
lastSyncedAt: z.date().nullable().optional(),
importStatus: z.string().nullable().optional(),
lastImportJobId: z.string().nullable().optional(),
lastImportMessage: z.string().nullable().optional(),
lastImportedAt: z.date().nullable().optional(),
removeStatus: z.string().nullable().optional(),
lastRemoveJobId: z.string().nullable().optional(),
lastRemoveMessage: z.string().nullable().optional(),
lastRemovedAt: z.date().nullable().optional()
});
export type TSecretSyncs = z.infer<typeof SecretSyncsSchema>;
export type TSecretSyncsInsert = Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>;
export type TSecretSyncsUpdate = Partial<Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>>;

View File

@ -24,6 +24,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
),
name: z.string().trim(),
description: z.string().trim().nullish(),
// TODO(scott): once UI refactored permissions: OrgPermissionSchema.array()
permissions: z.any().array()
}),
response: {
@ -96,6 +97,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
.optional(),
name: z.string().trim().optional(),
description: z.string().trim().nullish(),
// TODO(scott): once UI refactored permissions: OrgPermissionSchema.array().optional()
permissions: z.any().array().optional()
}),
response: {

View File

@ -81,7 +81,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, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
import {
TCreateSecretSyncDTO,
TDeleteSecretSyncDTO,
TSecretSyncRaw,
TUpdateSecretSyncDTO
} from "@app/services/secret-sync/secret-sync-types";
export type TListProjectAuditLogDTO = {
filter: {
@ -226,13 +233,22 @@ 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",
CREATE_SHARED_SECRET = "create-shared-secret",
DELETE_SHARED_SECRET = "delete-shared-secret",
READ_SHARED_SECRET = "read-shared-secret"
READ_SHARED_SECRET = "read-shared-secret",
GET_SECRET_SYNCS = "get-secret-syncs",
GET_SECRET_SYNC = "get-secret-sync",
CREATE_SECRET_SYNC = "create-secret-sync",
UPDATE_SECRET_SYNC = "update-secret-sync",
DELETE_SECRET_SYNC = "delete-secret-sync",
SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets",
SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets",
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets"
}
interface UserActorMetadata {
@ -1893,6 +1909,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: {
@ -1946,6 +1971,78 @@ interface ReadSharedSecretEvent {
};
}
interface GetSecretSyncsEvent {
type: EventType.GET_SECRET_SYNCS;
metadata: {
destination?: SecretSync;
count: number;
syncIds: string[];
};
}
interface GetSecretSyncEvent {
type: EventType.GET_SECRET_SYNC;
metadata: {
destination: SecretSync;
syncId: string;
};
}
interface CreateSecretSyncEvent {
type: EventType.CREATE_SECRET_SYNC;
metadata: Omit<TCreateSecretSyncDTO, "projectId"> & { syncId: string };
}
interface UpdateSecretSyncEvent {
type: EventType.UPDATE_SECRET_SYNC;
metadata: TUpdateSecretSyncDTO;
}
interface DeleteSecretSyncEvent {
type: EventType.DELETE_SECRET_SYNC;
metadata: TDeleteSecretSyncDTO;
}
interface SecretSyncSyncSecretsEvent {
type: EventType.SECRET_SYNC_SYNC_SECRETS;
metadata: Pick<
TSecretSyncRaw,
"syncOptions" | "destinationConfig" | "destination" | "syncStatus" | "connectionId" | "folderId"
> & {
syncId: string;
syncMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
interface SecretSyncImportSecretsEvent {
type: EventType.SECRET_SYNC_IMPORT_SECRETS;
metadata: Pick<
TSecretSyncRaw,
"syncOptions" | "destinationConfig" | "destination" | "importStatus" | "connectionId" | "folderId"
> & {
syncId: string;
importMessage: string | null;
jobId: string;
jobRanAt: Date;
importBehavior: SecretSyncImportBehavior;
};
}
interface SecretSyncRemoveSecretsEvent {
type: EventType.SECRET_SYNC_REMOVE_SECRETS;
metadata: Pick<
TSecretSyncRaw,
"syncOptions" | "destinationConfig" | "destination" | "removeStatus" | "connectionId" | "folderId"
> & {
syncId: string;
removeMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@ -2119,10 +2216,19 @@ export type Event =
| DeleteProjectTemplateEvent
| ApplyProjectTemplateEvent
| GetAppConnectionsEvent
| GetAvailableAppConnectionsDetailsEvent
| GetAppConnectionEvent
| CreateAppConnectionEvent
| UpdateAppConnectionEvent
| DeleteAppConnectionEvent
| CreateSharedSecretEvent
| DeleteSharedSecretEvent
| ReadSharedSecretEvent;
| ReadSharedSecretEvent
| GetSecretSyncsEvent
| GetSecretSyncEvent
| CreateSecretSyncEvent
| UpdateSecretSyncEvent
| DeleteSecretSyncEvent
| SecretSyncSyncSecretsEvent
| SecretSyncImportSecretsEvent
| SecretSyncRemoveSecretsEvent;

View File

@ -50,8 +50,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
},
pkiEst: false,
enforceMfa: false,
projectTemplates: false,
appConnections: false
projectTemplates: false
});
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {

View File

@ -68,7 +68,6 @@ export type TFeatureSet = {
pkiEst: boolean;
enforceMfa: boolean;
projectTemplates: false;
appConnections: false; // TODO: remove once live
};
export type TOrgPlansTableDTO = {

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,11 @@ 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.Read, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAppConnectionActions.Create, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAppConnectionActions.Edit, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAppConnectionActions.Delete, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
@ -160,7 +281,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";
@ -30,6 +34,16 @@ export enum ProjectPermissionDynamicSecretActions {
Lease = "lease"
}
export enum ProjectPermissionSecretSyncActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
SyncSecrets = "sync-secrets",
ImportSecrets = "import-secrets",
RemoveSecrets = "remove-secrets"
}
export enum ProjectPermissionSub {
Role = "role",
Member = "member",
@ -60,7 +74,8 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
Cmek = "cmek"
Cmek = "cmek",
SecretSyncs = "secret-syncs"
}
export type SecretSubjectFields = {
@ -140,6 +155,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs]
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
@ -147,14 +163,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 +400,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(ProjectPermissionSecretSyncActions).describe(
"Describe what action an entity can take."
)
})
];
@ -549,6 +562,18 @@ const buildAdminPermissionRules = () => {
],
ProjectPermissionSub.Cmek
);
can(
[
ProjectPermissionSecretSyncActions.Create,
ProjectPermissionSecretSyncActions.Edit,
ProjectPermissionSecretSyncActions.Delete,
ProjectPermissionSecretSyncActions.Read,
ProjectPermissionSecretSyncActions.SyncSecrets,
ProjectPermissionSecretSyncActions.ImportSecrets,
ProjectPermissionSecretSyncActions.RemoveSecrets
],
ProjectPermissionSub.SecretSyncs
);
return rules;
};
@ -713,6 +738,19 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.Cmek
);
can(
[
ProjectPermissionSecretSyncActions.Create,
ProjectPermissionSecretSyncActions.Edit,
ProjectPermissionSecretSyncActions.Delete,
ProjectPermissionSecretSyncActions.Read,
ProjectPermissionSecretSyncActions.SyncSecrets,
ProjectPermissionSecretSyncActions.ImportSecrets,
ProjectPermissionSecretSyncActions.RemoveSecrets
],
ProjectPermissionSub.SecretSyncs
);
return rules;
};
@ -746,6 +784,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
can(ProjectPermissionSecretSyncActions.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: {
@ -1643,6 +1645,83 @@ 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.`,
projectId: "The ID of the project to create the sync in.",
environment: `The slug of the project environment to sync secrets from.`,
secretPath: `The folder path to sync secrets from.`,
connectionId: `The ID of the ${
APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]]
} Connection to use for syncing.`,
isAutoSyncEnabled: `Whether secrets should be automatically synced when changes occur at the source location or not.`,
syncOptions: "Optional parameters to modify how secrets are synced."
};
},
UPDATE: (destination: SecretSync) => {
const destinationName = SECRET_SYNC_NAME_MAP[destination];
return {
syncId: `The ID of the ${destinationName} Sync to be updated.`,
connectionId: `The updated ID of the ${
APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]]
} Connection to use for syncing.`,
name: `The updated name of the ${destinationName} Sync. Must be slug-friendly.`,
environment: `The updated slug of the project environment to sync secrets from.`,
secretPath: `The updated folder path to sync secrets from.`,
description: `The updated description of the ${destinationName} Sync.`,
isAutoSyncEnabled: `Whether secrets should be automatically synced when changes occur at the source location or not.`,
syncOptions: "Optional parameters to modify how secrets are synced."
};
},
DELETE: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to be deleted.`,
removeSecrets: `Whether previously synced secrets should be removed prior to deletion.`
}),
SYNC_SECRETS: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger a sync for.`
}),
IMPORT_SECRETS: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger importing secrets for.`,
importBehavior: `Specify whether Infisical should prioritize secret values from Infisical or ${SECRET_SYNC_NAME_MAP[destination]}.`
}),
REMOVE_SECRETS: (destination: SecretSync) => ({
syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger removing secrets for.`
}),
SYNC_OPTIONS: (destination: SecretSync) => {
const destinationName = SECRET_SYNC_NAME_MAP[destination];
return {
INITIAL_SYNC_BEHAVIOR: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.`,
PREPEND_PREFIX: `Optionally prepend a prefix to your secrets' keys when syncing to ${destinationName}.`,
APPEND_SUFFIX: `Optionally append a suffix to your secrets' keys when syncing to ${destinationName}.`
};
},
DESTINATION_CONFIG: {
AWS_PARAMETER_STORE: {
REGION: "The AWS region to sync secrets to.",
PATH: "The Parameter Store path to sync secrets to."
},
GITHUB: {
ORG: "The name of the GitHub organization.",
OWNER: "The name of the GitHub account owner of the repository.",
REPO: "The name of the GitHub repository.",
ENV: "The name of the GitHub environment."
}
}
};

View File

@ -15,6 +15,12 @@ import {
TIntegrationSyncPayload,
TSyncSecretsDTO
} from "@app/services/secret/secret-types";
import {
TQueueSecretSyncImportSecretsByIdDTO,
TQueueSecretSyncRemoveSecretsByIdDTO,
TQueueSecretSyncSyncSecretsByIdDTO,
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",
SecretSyncSyncSecrets = "secret-sync-sync-secrets",
SecretSyncImportSecrets = "secret-sync-import-secrets",
SecretSyncRemoveSecrets = "secret-sync-remove-secrets",
SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications"
}
export type TQueueJobTypes = {
@ -184,6 +195,23 @@ export type TQueueJobTypes = {
};
};
};
[QueueName.AppConnectionSecretSync]:
| {
name: QueueJobs.SecretSyncSyncSecrets;
payload: TQueueSecretSyncSyncSecretsByIdDTO;
}
| {
name: QueueJobs.SecretSyncImportSecrets;
payload: TQueueSecretSyncImportSecretsByIdDTO;
}
| {
name: QueueJobs.SecretSyncRemoveSecrets;
payload: TQueueSecretSyncRemoveSecretsByIdDTO;
}
| {
name: QueueJobs.SecretSyncSendActionFailedNotifications;
payload: TQueueSendSecretSyncActionFailedNotificationsDTO;
};
};
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;

View File

@ -196,6 +196,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";
@ -318,6 +321,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);
@ -824,6 +828,29 @@ 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,
resourceMetadataDAL
});
const secretQueueService = secretQueueFactory({
keyStore,
queueService,
@ -858,7 +885,8 @@ export const registerRoutes = async (
projectKeyDAL,
projectUserMembershipRoleDAL,
orgService,
resourceMetadataDAL
resourceMetadataDAL,
secretSyncQueue
});
const projectService = projectServiceFactory({
@ -1369,8 +1397,17 @@ export const registerRoutes = async (
const appConnectionService = appConnectionServiceFactory({
appConnectionDAL,
permissionService,
kmsService,
licenseService
kmsService
});
const secretSyncService = secretSyncServiceFactory({
secretSyncDAL,
permissionService,
appConnectionService,
folderDAL,
secretSyncQueue,
projectBotService,
keyStore
});
await superAdminService.initServerCfg();
@ -1470,7 +1507,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]),
@ -105,7 +143,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
server.route({
method: "GET",
url: `/name/:connectionName`,
url: `/connection-name/:connectionName`,
config: {
rateLimit: readLimit
},
@ -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,17 +0,0 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateGitHubConnectionSchema,
SanitizedGitHubConnectionSchema,
UpdateGitHubConnectionSchema
} from "@app/services/app-connection/github";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) =>
registerAppConnectionEndpoints({
app: AppConnection.GitHub,
server,
responseSchema: SanitizedGitHubConnectionSchema,
createSchema: CreateGitHubConnectionSchema,
updateSchema: UpdateGitHubConnectionSchema
});

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

@ -0,0 +1,117 @@
import { z } from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateGitHubConnectionSchema,
SanitizedGitHubConnectionSchema,
UpdateGitHubConnectionSchema
} from "@app/services/app-connection/github";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.GitHub,
server,
sanitizedResponseSchema: SanitizedGitHubConnectionSchema,
createSchema: CreateGitHubConnectionSchema,
updateSchema: UpdateGitHubConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/repositories`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z.object({
repositories: z
.object({ id: z.number(), name: z.string(), owner: z.object({ login: z.string(), id: z.number() }) })
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { connectionId } = req.params;
const repositories = await server.services.appConnection.github.listRepositories(connectionId, req.permission);
return { repositories };
}
});
server.route({
method: "GET",
url: `/:connectionId/organizations`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z.object({
organizations: z.object({ id: z.number(), login: z.string() }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { connectionId } = req.params;
const organizations = await server.services.appConnection.github.listOrganizations(connectionId, req.permission);
return { organizations };
}
});
server.route({
method: "GET",
url: `/:connectionId/environments`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
querystring: z.object({
repo: z.string().min(1, "Repository name is required"),
owner: z.string().min(1, "Repository owner name is required")
}),
response: {
200: z.object({
environments: z.object({ id: z.number(), name: z.string() }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { connectionId } = req.params;
const { repo, owner } = req.query;
const environments = await server.services.appConnection.github.listEnvironments(
{
connectionId,
repo,
owner
},
req.permission
);
return { environments };
}
});
};

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

@ -1151,6 +1151,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
}
});
server.route({
method: "GET",
url: "/:integrationAuthId/vercel/custom-environments",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
querystring: z.object({
teamId: z.string().trim()
}),
params: z.object({
integrationAuthId: z.string().trim()
}),
response: {
200: z.object({
environments: z
.object({
appId: z.string(),
customEnvironments: z
.object({
id: z.string(),
slug: z.string()
})
.array()
})
.array()
})
}
},
handler: async (req) => {
const environments = await server.services.integrationAuth.getVercelCustomEnvironments({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.integrationAuthId,
teamId: req.query.teamId
});
return { environments };
}
});
server.route({
method: "GET",
url: "/:integrationAuthId/octopus-deploy/spaces",

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,13 @@
import { CreateGitHubSyncSchema, GitHubSyncSchema, UpdateGitHubSyncSchema } from "@app/services/secret-sync/github";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerGitHubSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.GitHub,
server,
responseSchema: GitHubSyncSchema,
createSchema: CreateGitHubSyncSchema,
updateSchema: UpdateGitHubSyncSchema
});

View File

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

View File

@ -0,0 +1,408 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SecretSyncs } from "@app/lib/api-docs";
import { startsWithVowel } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
import { TSecretSync, TSecretSyncInput } from "@app/services/secret-sync/secret-sync-types";
export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TSecretSyncInput>({
server,
destination,
createSchema,
updateSchema,
responseSchema
}: {
destination: SecretSync;
server: FastifyZodProvider;
createSchema: z.ZodType<{
name: string;
environment: string;
secretPath: string;
projectId: string;
connectionId: string;
destinationConfig: I["destinationConfig"];
syncOptions: I["syncOptions"];
description?: string | null;
isAutoSyncEnabled?: boolean;
}>;
updateSchema: z.ZodType<{
connectionId?: string;
name?: string;
environment?: string;
secretPath?: string;
destinationConfig?: I["destinationConfig"];
syncOptions?: I["syncOptions"];
description?: string | null;
isAutoSyncEnabled?: boolean;
}>;
responseSchema: z.ZodTypeAny;
}) => {
const destinationName = SECRET_SYNC_NAME_MAP[destination];
server.route({
method: "GET",
url: `/`,
config: {
rateLimit: readLimit
},
schema: {
description: `List the ${destinationName} Syncs for the specified project.`,
querystring: z.object({
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST(destination).projectId)
}),
response: {
200: z.object({ secretSyncs: responseSchema.array() })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const {
query: { projectId }
} = req;
const secretSyncs = (await server.services.secretSync.listSecretSyncsByProjectId(
{ projectId, destination },
req.permission
)) as T[];
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_SYNCS,
metadata: {
destination,
count: secretSyncs.length,
syncIds: secretSyncs.map((connection) => connection.id)
}
}
});
return { secretSyncs };
}
});
server.route({
method: "GET",
url: "/:syncId",
config: {
rateLimit: readLimit
},
schema: {
description: `Get the specified ${destinationName} Sync by ID.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.GET_BY_ID(destination).syncId)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const secretSync = (await server.services.secretSync.findSecretSyncById(
{ syncId, destination },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretSync.projectId,
event: {
type: EventType.GET_SECRET_SYNC,
metadata: {
syncId,
destination
}
}
});
return { secretSync };
}
});
server.route({
method: "GET",
url: `/sync-name/:syncName`,
config: {
rateLimit: readLimit
},
schema: {
description: `Get the specified ${destinationName} Sync by name and project ID.`,
params: z.object({
syncName: z.string().trim().min(1, "Sync name required").describe(SecretSyncs.GET_BY_NAME(destination).syncName)
}),
querystring: z.object({
projectId: z
.string()
.trim()
.min(1, "Project ID required")
.describe(SecretSyncs.GET_BY_NAME(destination).projectId)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncName } = req.params;
const { projectId } = req.query;
const secretSync = (await server.services.secretSync.findSecretSyncByName(
{ syncName, projectId, destination },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_SYNC,
metadata: {
syncId: secretSync.id,
destination
}
}
});
return { secretSync };
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: `Create ${
startsWithVowel(destinationName) ? "an" : "a"
} ${destinationName} Sync for the specified project environment.`,
body: createSchema,
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const secretSync = (await server.services.secretSync.createSecretSync(
{ ...req.body, destination },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretSync.projectId,
event: {
type: EventType.CREATE_SECRET_SYNC,
metadata: {
syncId: secretSync.id,
destination,
...req.body
}
}
});
return { secretSync };
}
});
server.route({
method: "PATCH",
url: "/:syncId",
config: {
rateLimit: writeLimit
},
schema: {
description: `Update the specified ${destinationName} Sync.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).syncId)
}),
body: updateSchema,
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const secretSync = (await server.services.secretSync.updateSecretSync(
{ ...req.body, syncId, destination },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretSync.projectId,
event: {
type: EventType.UPDATE_SECRET_SYNC,
metadata: {
syncId,
destination,
...req.body
}
}
});
return { secretSync };
}
});
server.route({
method: "DELETE",
url: `/:syncId`,
config: {
rateLimit: writeLimit
},
schema: {
description: `Delete the specified ${destinationName} Sync.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.DELETE(destination).syncId)
}),
querystring: z.object({
removeSecrets: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true")
.describe(SecretSyncs.DELETE(destination).removeSecrets)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const { removeSecrets } = req.query;
const secretSync = (await server.services.secretSync.deleteSecretSync(
{ destination, syncId, removeSecrets },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.DELETE_SECRET_SYNC,
metadata: {
destination,
syncId,
removeSecrets
}
}
});
return { secretSync };
}
});
server.route({
method: "POST",
url: "/:syncId/sync-secrets",
config: {
rateLimit: writeLimit
},
schema: {
description: `Trigger a sync for the specified ${destinationName} Sync.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.SYNC_SECRETS(destination).syncId)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const secretSync = (await server.services.secretSync.triggerSecretSyncSyncSecretsById(
{
syncId,
destination,
auditLogInfo: req.auditLogInfo
},
req.permission
)) as T;
return { secretSync };
}
});
server.route({
method: "POST",
url: "/:syncId/import-secrets",
config: {
rateLimit: writeLimit
},
schema: {
description: `Import secrets from the specified ${destinationName} Sync destination.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.IMPORT_SECRETS(destination).syncId)
}),
querystring: z.object({
importBehavior: z
.nativeEnum(SecretSyncImportBehavior)
.describe(SecretSyncs.IMPORT_SECRETS(destination).importBehavior)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const { importBehavior } = req.query;
const secretSync = (await server.services.secretSync.triggerSecretSyncImportSecretsById(
{
syncId,
destination,
importBehavior
},
req.permission
)) as T;
return { secretSync };
}
});
server.route({
method: "POST",
url: "/:syncId/remove-secrets",
config: {
rateLimit: writeLimit
},
schema: {
description: `Remove previously synced secrets from the specified ${destinationName} Sync destination.`,
params: z.object({
syncId: z.string().uuid().describe(SecretSyncs.REMOVE_SECRETS(destination).syncId)
}),
response: {
200: z.object({ secretSync: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { syncId } = req.params;
const secretSync = (await server.services.secretSync.triggerSecretSyncRemoveSecretsById(
{
syncId,
destination
},
req.permission
)) as T;
return { secretSync };
}
});
};

View File

@ -0,0 +1,82 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SecretSyncs } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import {
AwsParameterStoreSyncListItemSchema,
AwsParameterStoreSyncSchema
} from "@app/services/secret-sync/aws-parameter-store";
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
const SecretSyncSchema = z.discriminatedUnion("destination", [AwsParameterStoreSyncSchema, GitHubSyncSchema]);
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
AwsParameterStoreSyncListItemSchema,
GitHubSyncListItemSchema
]);
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/options",
config: {
rateLimit: readLimit
},
schema: {
description: "List the available Secret Sync Options.",
response: {
200: z.object({
secretSyncOptions: SecretSyncOptionsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: () => {
const secretSyncOptions = server.services.secretSync.listSecretSyncOptions();
return { secretSyncOptions };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List all the Secret Syncs for the specified project.",
querystring: z.object({
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST().projectId)
}),
response: {
200: z.object({ secretSyncs: SecretSyncSchema.array() })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const {
query: { projectId },
permission
} = req;
const secretSyncs = await server.services.secretSync.listSecretSyncsByProjectId({ projectId }, permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_SYNCS,
metadata: {
syncIds: secretSyncs.map((sync) => sync.id),
count: secretSyncs.length
}
}
});
return { secretSyncs };
}
});
};

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,12 @@
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,
@ -23,6 +22,7 @@ import {
} from "@app/services/app-connection/app-connection-types";
import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws";
import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github";
import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
@ -31,7 +31,6 @@ export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; // TODO: remove once launched
};
export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>;
@ -44,19 +43,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
export const appConnectionServiceFactory = ({
appConnectionDAL,
permissionService,
kmsService,
licenseService
kmsService
}: TAppConnectionServiceFactoryDep) => {
// app connections are disabled for public until launch
const checkAppServicesAvailability = async (orgId: string) => {
const subscription = await licenseService.getPlan(orgId);
if (!subscription.appConnections) throw new BadRequestError({ message: "App Connections are not available yet." });
};
const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => {
await checkAppServicesAvailability(actor.orgId);
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@ -65,7 +54,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,24 +70,11 @@ 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))
);
};
const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => {
await checkAppServicesAvailability(actor.orgId);
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
@ -108,24 +87,18 @@ 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) => {
await checkAppServicesAvailability(actor.orgId);
const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId });
if (!appConnection)
@ -139,27 +112,21 @@ 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 (
{ method, app, credentials, ...params }: TCreateAppConnectionDTO,
actor: OrgServiceActor
) => {
await checkAppServicesAvailability(actor.orgId);
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@ -168,7 +135,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,15 +186,13 @@ export const appConnectionServiceFactory = ({
};
});
return appConnection;
return appConnection as TAppConnection;
};
const updateAppConnection = async (
{ connectionId, credentials, ...params }: TUpdateAppConnectionDTO,
actor: OrgServiceActor
) => {
await checkAppServicesAvailability(actor.orgId);
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
@ -237,7 +205,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,19 +275,10 @@ 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) => {
await checkAppServicesAvailability(actor.orgId);
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
@ -329,23 +291,85 @@ 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 <T extends TAppConnection>(
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => {
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` });
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
appConnection.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(orgPermission).throwUnlessCan(
OrgPermissionAppConnectionActions.Connect,
subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id })
);
if (appConnection.app !== app)
throw new BadRequestError({
message: `${
APP_CONNECTION_NAME_MAP[appConnection.app as AppConnection]
} Connection with ID ${connectionId} cannot be used to connect to ${APP_CONNECTION_NAME_MAP[app]}`
});
const connection = await decryptAppConnection(appConnection, kmsService);
return connection as T;
};
const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor) => {
const { permission: orgPermission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
const appConnections = await appConnectionDAL.find({ app, orgId: actor.orgId });
const availableConnections = appConnections.filter((connection) =>
orgPermission.can(
OrgPermissionAppConnectionActions.Connect,
subject(OrgPermissionSubjects.AppConnections, { connectionId: connection.id })
)
);
return availableConnections as Omit<TAppConnection, "credentials">[];
};
return {
@ -355,6 +379,9 @@ export const appConnectionServiceFactory = ({
findAppConnectionByName,
createAppConnection,
updateAppConnection,
deleteAppConnection
deleteAppConnection,
connectAppConnectionById,
listAvailableAppConnectionsForUser,
github: githubConnectionService(connectAppConnectionById)
};
};

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;
@ -81,11 +81,14 @@ export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig
};
export const validateAwsConnectionCredentials = async (appConnection: TAwsConnectionConfig) => {
const awsConfig = await getAwsConnectionConfig(appConnection);
const sts = new AWS.STS(awsConfig);
let resp: Awaited<ReturnType<ReturnType<typeof sts.getCallerIdentity>["promise"]>>;
let resp: AWS.STS.GetCallerIdentityResponse & {
$response: AWS.Response<AWS.STS.GetCallerIdentityResponse, AWS.AWSError>;
};
try {
const awsConfig = await getAwsConnectionConfig(appConnection);
const sts = new AWS.STS(awsConfig);
resp = await sts.getCallerIdentity().promise();
} catch (e: unknown) {
throw new BadRequestError({
@ -93,7 +96,7 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec
});
}
if (resp.$response.httpResponse.statusCode !== 200)
if (resp?.$response.httpResponse.statusCode !== 200)
throw new InternalServerError({
message: `Unable to validate credentials: ${
resp.$response.error?.message ??

View File

@ -38,11 +38,11 @@ export const AwsConnectionSchema = z.intersection(
export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true })
credentials: AwsConnectionAssumeRoleCredentialsSchema.pick({})
}),
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true })
credentials: AwsConnectionAccessTokenCredentialsSchema.pick({ accessKeyId: true })
})
]);
@ -75,7 +75,7 @@ export const UpdateAwsConnectionSchema = z
export const AwsConnectionListItemSchema = z.object({
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

@ -1,3 +1,5 @@
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
import { AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env";
@ -8,7 +10,7 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
import { AppConnection } from "../app-connection-enums";
import { GitHubConnectionMethod } from "./github-connection-enums";
import { TGitHubConnectionConfig } from "./github-connection-types";
import { TGitHubConnection, TGitHubConnectionConfig } from "./github-connection-types";
export const getGitHubConnectionListItem = () => {
const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig();
@ -22,10 +24,131 @@ export const getGitHubConnectionListItem = () => {
};
};
export const getGitHubClient = (appConnection: TGitHubConnection) => {
const appCfg = getConfig();
const { method, credentials } = appConnection;
let client: Octokit;
switch (method) {
case GitHubConnectionMethod.App:
if (!appCfg.INF_APP_CONNECTION_GITHUB_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY) {
throw new InternalServerError({
message: `GitHub ${getAppConnectionMethodName(method).replace(
"GitHub",
""
)} environment variables have not been configured`
});
}
client = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appCfg.INF_APP_CONNECTION_GITHUB_APP_ID,
privateKey: appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY,
installationId: credentials.installationId
}
});
break;
case GitHubConnectionMethod.OAuth:
client = new Octokit({
auth: credentials.accessToken
});
break;
default:
throw new InternalServerError({
message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}`
});
}
return client;
};
type GitHubOrganization = {
login: string;
id: number;
};
type GitHubRepository = {
id: number;
name: string;
owner: GitHubOrganization;
};
export const getGitHubRepositories = async (appConnection: TGitHubConnection) => {
const client = getGitHubClient(appConnection);
let repositories: GitHubRepository[];
switch (appConnection.method) {
case GitHubConnectionMethod.App:
repositories = await client.paginate("GET /installation/repositories");
break;
case GitHubConnectionMethod.OAuth:
default:
repositories = (await client.paginate("GET /user/repos")).filter((repo) => repo.permissions?.admin);
break;
}
return repositories;
};
export const getGitHubOrganizations = async (appConnection: TGitHubConnection) => {
const client = getGitHubClient(appConnection);
let organizations: GitHubOrganization[];
switch (appConnection.method) {
case GitHubConnectionMethod.App: {
const installationRepositories = await client.paginate("GET /installation/repositories");
const organizationMap: Record<string, GitHubOrganization> = {};
installationRepositories.forEach((repo) => {
if (repo.owner.type === "Organization") {
organizationMap[repo.owner.id] = repo.owner;
}
});
organizations = Object.values(organizationMap);
break;
}
case GitHubConnectionMethod.OAuth:
default:
organizations = await client.paginate("GET /user/orgs");
break;
}
return organizations;
};
export const getGitHubEnvironments = async (appConnection: TGitHubConnection, owner: string, repo: string) => {
const client = getGitHubClient(appConnection);
try {
const environments = await client.paginate("GET /repos/{owner}/{repo}/environments", {
owner,
repo
});
return environments;
} catch (e) {
// repo doesn't have envs
if ((e as { status: number }).status === 404) {
return [];
}
throw e;
}
};
type TokenRespData = {
access_token: string;
scope: string;
token_type: string;
error?: string;
};
export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => {
@ -53,7 +176,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
if (!clientId || !clientSecret) {
throw new InternalServerError({
message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured`
message: `GitHub ${getAppConnectionMethodName(method).replace(
"GitHub",
""
)} environment variables have not been configured`
});
}
@ -65,7 +191,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
client_id: clientId,
client_secret: clientSecret,
code: credentials.code,
redirect_uri: `${SITE_URL}/app-connections/github/oauth/callback`
redirect_uri: `${SITE_URL}/organization/app-connections/github/oauth/callback`
},
headers: {
Accept: "application/json",
@ -90,6 +216,8 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
id: number;
account: {
login: string;
type: string;
id: number;
};
}[];
}>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, {
@ -111,10 +239,13 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
}
}
if (!tokenResp.data.access_token) {
throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` });
}
switch (method) {
case GitHubConnectionMethod.App:
return {
// access token not needed for GitHub App
installationId: credentials.installationId
};
case GitHubConnectionMethod.OAuth:

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({
@ -74,19 +74,19 @@ export const GitHubAppConnectionSchema = z.intersection(
export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true })
credentials: GitHubConnectionAppOutputCredentialsSchema.pick({})
}),
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true })
credentials: GitHubConnectionOAuthOutputCredentialsSchema.pick({})
})
]);
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

@ -0,0 +1,55 @@
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
getGitHubEnvironments,
getGitHubOrganizations,
getGitHubRepositories
} from "@app/services/app-connection/github/github-connection-fns";
import { TGitHubConnection } from "@app/services/app-connection/github/github-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TGitHubConnection>;
type TListGitHubEnvironmentsDTO = {
connectionId: string;
repo: string;
owner: string;
};
export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listRepositories = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
const repositories = await getGitHubRepositories(appConnection);
return repositories;
};
const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
const organizations = await getGitHubOrganizations(appConnection);
return organizations;
};
const listEnvironments = async (
{ connectionId, repo, owner }: TListGitHubEnvironmentsDTO,
actor: OrgServiceActor
) => {
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
const environments = await getGitHubEnvironments(appConnection, owner, repo);
return environments;
};
return {
listRepositories,
listOrganizations,
listEnvironments
};
};

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

@ -132,16 +132,26 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => {
/**
* Return list of names of apps for Vercel integration
* This is re-used for getting custom environments for Vercel
*/
const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => {
const apps: Array<{ name: string; appId: string }> = [];
export const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => {
const apps: Array<{ name: string; appId: string; customEnvironments: Array<{ slug: string; id: string }> }> = [];
const limit = "20";
let hasMorePages = true;
let next: number | null = null;
interface Response {
projects: { name: string; id: string }[];
projects: {
name: string;
id: string;
customEnvironments?: {
id: string;
type: string;
description: string;
slug: string;
}[];
}[];
pagination: {
count: number;
next: number | null;
@ -173,7 +183,12 @@ const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null;
data.projects.forEach((a) => {
apps.push({
name: a.name,
appId: a.id
appId: a.id,
customEnvironments:
a.customEnvironments?.map((env) => ({
slug: env.slug,
id: env.id
})) ?? []
});
});

View File

@ -25,11 +25,12 @@ import { TIntegrationDALFactory } from "../integration/integration-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { getApps } from "./integration-app-list";
import { getApps, getAppsVercel } from "./integration-app-list";
import { TCircleCIContext } from "./integration-app-types";
import { TIntegrationAuthDALFactory } from "./integration-auth-dal";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import {
GetVercelCustomEnvironmentsDTO,
OctopusDeployScope,
TBitbucketEnvironment,
TBitbucketWorkspace,
@ -1825,6 +1826,41 @@ export const integrationAuthServiceFactory = ({
return integrationAuthDAL.create(newIntegrationAuth);
};
const getVercelCustomEnvironments = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
teamId,
id
}: GetVercelCustomEnvironmentsDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: integrationAuth.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const vercelApps = await getAppsVercel({
accessToken,
teamId
});
return vercelApps.map((app) => ({
customEnvironments: app.customEnvironments,
appId: app.appId
}));
};
const getOctopusDeploySpaces = async ({
actorId,
actor,
@ -1944,6 +1980,7 @@ export const integrationAuthServiceFactory = ({
getIntegrationAccessToken,
duplicateIntegrationAuth,
getOctopusDeploySpaces,
getOctopusDeployScopeValues
getOctopusDeployScopeValues,
getVercelCustomEnvironments
};
};

View File

@ -284,3 +284,8 @@ export type TOctopusDeployVariableSet = {
Self: string;
};
};
export type GetVercelCustomEnvironmentsDTO = {
teamId: string;
id: string;
} & Omit<TProjectPermission, "projectId">;

View File

@ -1450,9 +1450,13 @@ const syncSecretsVercel = async ({
secrets: Record<string, { value: string; comment?: string } | null>;
accessToken: string;
}) => {
const isCustomEnvironment = !["development", "preview", "production"].includes(
integration.targetEnvironment as string
);
interface VercelSecret {
id?: string;
type: string;
customEnvironmentIds?: string[];
key: string;
value: string;
target: string[];
@ -1486,6 +1490,16 @@ const syncSecretsVercel = async ({
}
)
).data.envs.filter((secret) => {
if (isCustomEnvironment) {
if (!secret.customEnvironmentIds?.includes(integration.targetEnvironment as string)) {
// case: secret does not have the same custom environment
return false;
}
// no need to check for preview environment, as custom environments are not available in preview
return true;
}
if (!secret.target.includes(integration.targetEnvironment as string)) {
// case: secret does not have the same target environment
return false;
@ -1583,7 +1597,13 @@ const syncSecretsVercel = async ({
key,
value: infisicalSecrets[key]?.value,
type: "encrypted",
target: [integration.targetEnvironment as string],
...(isCustomEnvironment
? {
customEnvironmentIds: [integration.targetEnvironment as string]
}
: {
target: [integration.targetEnvironment as string]
}),
...(integration.path
? {
gitBranch: integration.path
@ -1607,9 +1627,19 @@ const syncSecretsVercel = async ({
key,
value: infisicalSecrets[key]?.value,
type: res[key].type,
target: res[key].target.includes(integration.targetEnvironment as string)
? [...res[key].target]
: [...res[key].target, integration.targetEnvironment as string],
...(!isCustomEnvironment
? {
target: res[key].target.includes(integration.targetEnvironment as string)
? [...res[key].target]
: [...res[key].target, integration.targetEnvironment as string]
}
: {
customEnvironmentIds: res[key].customEnvironmentIds?.includes(integration.targetEnvironment as string)
? [...(res[key].customEnvironmentIds || [])]
: [...(res[key]?.customEnvironmentIds || []), integration.targetEnvironment as string]
}),
...(integration.path
? {
gitBranch: integration.path

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

View File

@ -0,0 +1,207 @@
import AWS, { AWSError } from "aws-sdk";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types";
type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
const MAX_RETRIES = 5;
const BATCH_SIZE = 10;
const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => {
const { destinationConfig, connection } = secretSync;
const config = await getAwsConnectionConfig(connection, destinationConfig.region);
const ssm = new AWS.SSM({
apiVersion: "2014-11-06",
region: destinationConfig.region
});
ssm.config.update(config);
return ssm;
};
const sleep = async () =>
new Promise((resolve) => {
setTimeout(resolve, 1000);
});
const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => {
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
let hasNext = true;
let nextToken: string | undefined;
let attempt = 0;
while (hasNext) {
try {
// eslint-disable-next-line no-await-in-loop
const parameters = await ssm
.getParametersByPath({
Path: path,
Recursive: false,
WithDecryption: true,
MaxResults: BATCH_SIZE,
NextToken: nextToken
})
.promise();
attempt = 0;
if (parameters.Parameters) {
parameters.Parameters.forEach((parameter) => {
if (parameter.Name) {
// no leading slash if path is '/'
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreSecretsRecord[secKey] = parameter;
}
});
}
hasNext = Boolean(parameters.NextToken);
nextToken = parameters.NextToken;
} catch (e) {
if ((e as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
attempt += 1;
// eslint-disable-next-line no-await-in-loop
await sleep();
}
throw e;
}
}
return awsParameterStoreSecretsRecord;
};
const putParameter = async (
ssm: AWS.SSM,
params: AWS.SSM.PutParameterRequest,
attempt = 0
): Promise<AWS.SSM.PutParameterResult> => {
try {
return await ssm.putParameter(params).promise();
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return putParameter(ssm, params, attempt + 1);
}
throw error;
}
};
const deleteParametersBatch = async (
ssm: AWS.SSM,
parameters: AWS.SSM.Parameter[],
attempt = 0
): Promise<AWS.SSM.DeleteParameterResult[]> => {
const results: AWS.SSM.DeleteParameterResult[] = [];
let remainingParams = [...parameters];
while (remainingParams.length > 0) {
const batch = remainingParams.slice(0, BATCH_SIZE);
try {
// eslint-disable-next-line no-await-in-loop
const result = await ssm.deleteParameters({ Names: batch.map((param) => param.Name!) }).promise();
results.push(result);
remainingParams = remainingParams.slice(BATCH_SIZE);
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
// eslint-disable-next-line no-await-in-loop
await sleep();
// Retry the current batch
// eslint-disable-next-line no-await-in-loop
return [...results, ...(await deleteParametersBatch(ssm, remainingParams, attempt + 1))];
}
throw error;
}
}
return results;
};
export const AwsParameterStoreSyncFns = {
syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const ssm = await getSSM(secretSync);
// TODO(scott): KMS Key ID, Tags
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
// skip empty values (not allowed by AWS) or secrets that haven't changed
if (!value || (key in awsParameterStoreSecretsRecord && awsParameterStoreSecretsRecord[key].Value === value)) {
// eslint-disable-next-line no-continue
continue;
}
try {
await putParameter(ssm, {
Name: `${destinationConfig.path}${key}`,
Type: "SecureString",
Value: value,
Overwrite: true
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
const parametersToDelete: AWS.SSM.Parameter[] = [];
for (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
const [key, parameter] = entry;
if (!(key in secretMap) || !secretMap[key].value) {
parametersToDelete.push(parameter);
}
}
await deleteParametersBatch(ssm, parametersToDelete);
},
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
const { destinationConfig } = secretSync;
const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
return Object.fromEntries(
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
);
},
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const ssm = await getSSM(secretSync);
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
const parametersToDelete: AWS.SSM.Parameter[] = [];
for (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
const [key, param] = entry;
if (key in secretMap) {
parametersToDelete.push(param);
}
}
await deleteParametersBatch(ssm, parametersToDelete);
}
};

View File

@ -0,0 +1,45 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
const AwsParameterStoreSyncDestinationConfigSchema = z.object({
region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.REGION),
path: z
.string()
.trim()
.min(1, "Parameter Store Path required")
.max(2048, "Cannot exceed 2048 characters")
.regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format')
.describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.PATH)
});
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(SecretSync.AWSParameterStore).extend({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
});
export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AWSParameterStore
).extend({
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
});
export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AWSParameterStore
).extend({
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional()
});
export const AwsParameterStoreSyncListItemSchema = z.object({
name: z.literal("AWS Parameter Store"),
connection: z.literal(AppConnection.AWS),
destination: z.literal(SecretSync.AWSParameterStore),
canImportSecrets: z.literal(true)
});

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 TAwsParameterStoreSyncWithCredentials = TAwsParameterStoreSync & {
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,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const GITHUB_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "GitHub",
destination: SecretSync.GitHub,
connection: AppConnection.GitHub,
canImportSecrets: false
};

View File

@ -0,0 +1,11 @@
export enum GitHubSyncScope {
Repository = "repository",
Organization = "organization",
RepositoryEnvironment = "repository-environment"
}
export enum GitHubSyncVisibility {
All = "all",
Private = "private",
Selected = "selected"
}

View File

@ -0,0 +1,242 @@
import { Octokit } from "@octokit/rest";
import sodium from "libsodium-wrappers";
import { getGitHubClient } from "@app/services/app-connection/github";
import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithCredentials } from "./github-sync-types";
// TODO: rate limit handling
const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => {
let encryptedSecrets: TGitHubSecret[];
const { destinationConfig } = secretSync;
switch (destinationConfig.scope) {
case GitHubSyncScope.Organization: {
encryptedSecrets = await client.paginate("GET /orgs/{org}/actions/secrets", {
org: destinationConfig.org
});
break;
}
case GitHubSyncScope.Repository: {
encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/actions/secrets", {
owner: destinationConfig.owner,
repo: destinationConfig.repo
});
break;
}
case GitHubSyncScope.RepositoryEnvironment:
default: {
encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", {
owner: destinationConfig.owner,
repo: destinationConfig.repo,
environment_name: destinationConfig.env
});
break;
}
}
return encryptedSecrets;
};
const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => {
let publicKey: TGitHubPublicKey;
const { destinationConfig } = secretSync;
switch (destinationConfig.scope) {
case GitHubSyncScope.Organization: {
publicKey = (
await client.request("GET /orgs/{org}/actions/secrets/public-key", {
org: destinationConfig.org
})
).data;
break;
}
case GitHubSyncScope.Repository: {
publicKey = (
await client.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", {
owner: destinationConfig.owner,
repo: destinationConfig.repo
})
).data;
break;
}
case GitHubSyncScope.RepositoryEnvironment:
default: {
publicKey = (
await client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", {
owner: destinationConfig.owner,
repo: destinationConfig.repo,
environment_name: destinationConfig.env
})
).data;
break;
}
}
return publicKey;
};
const deleteSecret = async (
client: Octokit,
secretSync: TGitHubSyncWithCredentials,
encryptedSecret: TGitHubSecret
) => {
const { destinationConfig } = secretSync;
switch (destinationConfig.scope) {
case GitHubSyncScope.Organization: {
await client.request(`DELETE /orgs/{org}/actions/secrets/{secret_name}`, {
org: destinationConfig.org,
secret_name: encryptedSecret.name
});
break;
}
case GitHubSyncScope.Repository: {
await client.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
owner: destinationConfig.owner,
repo: destinationConfig.repo,
secret_name: encryptedSecret.name
});
break;
}
case GitHubSyncScope.RepositoryEnvironment:
default: {
await client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
owner: destinationConfig.owner,
repo: destinationConfig.repo,
environment_name: destinationConfig.env,
secret_name: encryptedSecret.name
});
break;
}
}
};
const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: TGitHubSecretPayload) => {
const { destinationConfig } = secretSync;
switch (destinationConfig.scope) {
case GitHubSyncScope.Organization: {
const { visibility, selectedRepositoryIds } = destinationConfig;
await client.request(`PUT /orgs/{org}/actions/secrets/{secret_name}`, {
org: destinationConfig.org,
...payload,
visibility,
...(visibility === GitHubSyncVisibility.Selected && {
selected_repository_ids: selectedRepositoryIds
})
});
break;
}
case GitHubSyncScope.Repository: {
await client.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
owner: destinationConfig.owner,
repo: destinationConfig.repo,
...payload
});
break;
}
case GitHubSyncScope.RepositoryEnvironment:
default: {
await client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
owner: destinationConfig.owner,
repo: destinationConfig.repo,
environment_name: destinationConfig.env,
...payload
});
break;
}
}
};
export const GithubSyncFns = {
syncSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => {
switch (secretSync.destinationConfig.scope) {
case GitHubSyncScope.Organization:
if (Object.values(secretMap).length > 1000) {
throw new SecretSyncError({
message: "GitHub does not support storing more than 1,000 secrets at the organization level.",
shouldRetry: false
});
}
break;
case GitHubSyncScope.Repository:
case GitHubSyncScope.RepositoryEnvironment:
if (Object.values(secretMap).length > 100) {
throw new SecretSyncError({
message: "GitHub does not support storing more than 100 secrets at the repository level.",
shouldRetry: false
});
}
break;
default:
throw new Error(
`Unsupported GitHub Sync scope ${
(secretSync.destinationConfig as TGitHubSyncWithCredentials["destinationConfig"]).scope
}`
);
}
const client = getGitHubClient(secretSync.connection);
const encryptedSecrets = await getEncryptedSecrets(client, secretSync);
const publicKey = await getPublicKey(client, secretSync);
for await (const encryptedSecret of encryptedSecrets) {
if (!(encryptedSecret.name in secretMap)) {
await deleteSecret(client, secretSync, encryptedSecret);
}
}
await sodium.ready.then(async () => {
for await (const key of Object.keys(secretMap)) {
// convert secret & base64 key to Uint8Array.
const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL);
const binarySecretValue = sodium.from_string(secretMap[key].value);
// encrypt secret using libsodium
const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey);
// convert encrypted Uint8Array to base64
const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
try {
await putSecret(client, secretSync, {
secret_name: key,
encrypted_value: encryptedSecretValue,
key_id: publicKey.key_id
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
});
},
getSecrets: async (secretSync: TGitHubSyncWithCredentials) => {
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`);
},
removeSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => {
const client = getGitHubClient(secretSync.connection);
const encryptedSecrets = await getEncryptedSecrets(client, secretSync);
for await (const encryptedSecret of encryptedSecrets) {
if (encryptedSecret.name in secretMap) {
await deleteSecret(client, secretSync, encryptedSecret);
}
}
}
};

View File

@ -0,0 +1,82 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const GitHubSyncDestinationConfigSchema = z
.discriminatedUnion("scope", [
z.object({
scope: z.literal(GitHubSyncScope.Organization),
org: z.string().min(1, "Organization name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ORG),
visibility: z.nativeEnum(GitHubSyncVisibility),
selectedRepositoryIds: z.number().array().optional()
}),
z.object({
scope: z.literal(GitHubSyncScope.Repository),
owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER),
repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO)
}),
z.object({
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER),
repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO),
env: z.string().min(1, "Environment name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ENV)
})
])
.superRefine((options, ctx) => {
if (options.scope === GitHubSyncScope.Organization) {
if (options.visibility === GitHubSyncVisibility.Selected) {
if (!options.selectedRepositoryIds?.length)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Select at least 1 repository",
path: ["selectedRepositoryIds"]
});
return;
}
if (options.selectedRepositoryIds?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Selected repositories is only supported for visibility "Selected"`,
path: ["selectedRepositoryIds"]
});
}
}
});
const GitHubSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
export const GitHubSyncSchema = BaseSecretSyncSchema(SecretSync.GitHub, GitHubSyncOptionsConfig).extend({
destination: z.literal(SecretSync.GitHub),
destinationConfig: GitHubSyncDestinationConfigSchema
});
export const CreateGitHubSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.GitHub,
GitHubSyncOptionsConfig
).extend({
destinationConfig: GitHubSyncDestinationConfigSchema
});
export const UpdateGitHubSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.GitHub,
GitHubSyncOptionsConfig
).extend({
destinationConfig: GitHubSyncDestinationConfigSchema.optional()
});
export const GitHubSyncListItemSchema = z.object({
name: z.literal("GitHub"),
connection: z.literal(AppConnection.GitHub),
destination: z.literal(SecretSync.GitHub),
canImportSecrets: z.literal(false)
});

View File

@ -0,0 +1,38 @@
import { z } from "zod";
import { TGitHubConnection } from "@app/services/app-connection/github";
import { CreateGitHubSyncSchema, GitHubSyncListItemSchema, GitHubSyncSchema } from "./github-sync-schemas";
export type TGitHubSync = z.infer<typeof GitHubSyncSchema>;
export type TGitHubSyncInput = z.infer<typeof CreateGitHubSyncSchema>;
export type TGitHubSyncListItem = z.infer<typeof GitHubSyncListItemSchema>;
export type TGitHubSyncWithCredentials = TGitHubSync & {
connection: TGitHubConnection;
};
export type TGitHubSecret = {
name: string;
created_at: string;
updated_at: string;
visibility?: "all" | "private" | "selected";
selected_repositories_url?: string | undefined;
};
export type TGitHubPublicKey = {
key_id: string;
key: string;
id?: number | undefined;
url?: string | undefined;
title?: string | undefined;
created_at?: string | undefined;
};
export type TGitHubSecretPayload = {
key_id: string;
secret_name: string;
encrypted_value: string;
};

View File

@ -0,0 +1,4 @@
export * from "./github-sync-constants";
export * from "./github-sync-fns";
export * from "./github-sync-schemas";
export * from "./github-sync-types";

View File

@ -0,0 +1,212 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TSecretSyncs } from "@app/db/schemas/secret-syncs";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
export type TSecretSyncDALFactory = ReturnType<typeof secretSyncDALFactory>;
type SecretSyncFindFilter = Parameters<typeof buildFindFilter<TSecretSyncs>>[0];
const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: SecretSyncFindFilter; tx?: Knex }) => {
const query = (tx || db.replicaNode())(TableName.SecretSync)
.leftJoin(TableName.SecretFolder, `${TableName.SecretSync}.folderId`, `${TableName.SecretFolder}.id`)
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(TableName.AppConnection, `${TableName.SecretSync}.connectionId`, `${TableName.AppConnection}.id`)
.select(selectAllTableCols(TableName.SecretSync))
.select(
// environment
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("id").withSchema(TableName.Environment).as("envId"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
// entire connection
db.ref("name").withSchema(TableName.AppConnection).as("connectionName"),
db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"),
db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"),
db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"),
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"),
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt")
);
// prepends table name to filter keys to avoid ambiguous col references, skipping utility filters like $in, etc.
const prependTableName = (filterObj: object): SecretSyncFindFilter =>
Object.fromEntries(
Object.entries(filterObj).map(([key, value]) =>
key.startsWith("$") ? [key, prependTableName(value as object)] : [`${TableName.SecretSync}.${key}`, value]
)
);
if (filter) {
/* eslint-disable @typescript-eslint/no-misused-promises */
void query.where(buildFindFilter(prependTableName(filter)));
}
return query;
};
const expandSecretSync = (
secretSync: Awaited<ReturnType<typeof baseSecretSyncQuery>>[number],
folder?: Awaited<ReturnType<TSecretFolderDALFactory["findSecretPathByFolderIds"]>>[number]
) => {
const {
envId,
envName,
envSlug,
connectionApp,
connectionName,
connectionId,
connectionOrgId,
connectionEncryptedCredentials,
connectionMethod,
connectionDescription,
connectionCreatedAt,
connectionUpdatedAt,
connectionVersion,
...el
} = secretSync;
return {
...el,
connectionId,
environment: envId ? { id: envId, name: envName, slug: envSlug } : null,
connection: {
app: connectionApp,
id: connectionId,
name: connectionName,
orgId: connectionOrgId,
encryptedCredentials: connectionEncryptedCredentials,
method: connectionMethod,
description: connectionDescription,
createdAt: connectionCreatedAt,
updatedAt: connectionUpdatedAt,
version: connectionVersion
},
folder: folder
? {
id: folder.id,
path: folder.path
}
: null
};
};
export const secretSyncDALFactory = (
db: TDbClient,
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">
) => {
const secretSyncOrm = ormify(db, TableName.SecretSync);
const findById = async (id: string, tx?: Knex) => {
try {
const secretSync = await baseSecretSyncQuery({
filter: { id },
db,
tx
}).first();
if (secretSync) {
// TODO (scott): replace with cached folder path once implemented
const [folderWithPath] = secretSync.folderId
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
: [];
return expandSecretSync(secretSync, folderWithPath);
}
} catch (error) {
throw new DatabaseError({ error, name: "Find by ID - Secret Sync" });
}
};
const create = async (data: Parameters<(typeof secretSyncOrm)["create"]>[0]) => {
try {
const secretSync = (await secretSyncOrm.transaction(async (tx) => {
const sync = await secretSyncOrm.create(data, tx);
return baseSecretSyncQuery({
filter: { id: sync.id },
db,
tx
}).first();
}))!;
// TODO (scott): replace with cached folder path once implemented
const [folderWithPath] = secretSync.folderId
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
: [];
return expandSecretSync(secretSync, folderWithPath);
} catch (error) {
throw new DatabaseError({ error, name: "Create - Secret Sync" });
}
};
const updateById = async (syncId: string, data: Parameters<(typeof secretSyncOrm)["updateById"]>[1]) => {
try {
const secretSync = (await secretSyncOrm.transaction(async (tx) => {
const sync = await secretSyncOrm.updateById(syncId, data, tx);
return baseSecretSyncQuery({
filter: { id: sync.id },
db,
tx
}).first();
}))!;
// TODO (scott): replace with cached folder path once implemented
const [folderWithPath] = secretSync.folderId
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
: [];
return expandSecretSync(secretSync, folderWithPath);
} catch (error) {
throw new DatabaseError({ error, name: "Update by ID - Secret Sync" });
}
};
const findOne = async (filter: Parameters<(typeof secretSyncOrm)["findOne"]>[0], tx?: Knex) => {
try {
const secretSync = await baseSecretSyncQuery({ filter, db, tx }).first();
if (secretSync) {
// TODO (scott): replace with cached folder path once implemented
const [folderWithPath] = secretSync.folderId
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
: [];
return expandSecretSync(secretSync, folderWithPath);
}
} catch (error) {
throw new DatabaseError({ error, name: "Find One - Secret Sync" });
}
};
const find = async (filter: Parameters<(typeof secretSyncOrm)["find"]>[0], tx?: Knex) => {
try {
const secretSyncs = await baseSecretSyncQuery({ filter, db, tx });
if (!secretSyncs.length) return [];
const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
secretSyncs[0].projectId,
secretSyncs.filter((sync) => Boolean(sync.folderId)).map((sync) => sync.folderId!)
);
// TODO (scott): replace with cached folder path once implemented
const folderRecord: Record<string, (typeof foldersWithPath)[number]> = {};
foldersWithPath.forEach((folder) => {
if (folder) folderRecord[folder.id] = folder;
});
return secretSyncs.map((secretSync) =>
expandSecretSync(secretSync, secretSync.folderId ? folderRecord[secretSync.folderId] : undefined)
);
} catch (error) {
throw new DatabaseError({ error, name: "Find - Secret Sync" });
}
};
return { ...secretSyncOrm, findById, findOne, find, create, updateById };
};

View File

@ -0,0 +1,15 @@
export enum SecretSync {
AWSParameterStore = "aws-parameter-store",
GitHub = "github"
}
export enum SecretSyncInitialSyncBehavior {
OverwriteDestination = "overwrite-destination",
ImportPrioritizeSource = "import-prioritize-source",
ImportPrioritizeDestination = "import-prioritize-destination"
}
export enum SecretSyncImportBehavior {
PrioritizeSource = "prioritize-source",
PrioritizeDestination = "prioritize-destination"
}

View File

@ -0,0 +1,23 @@
export class SecretSyncError extends Error {
name: string;
error?: unknown;
secretKey?: string;
shouldRetry?: boolean;
constructor({
name,
error,
secretKey,
message,
shouldRetry = true
}: { name?: string; error?: unknown; secretKey?: string; shouldRetry?: boolean; message?: string } = {}) {
super(message);
this.name = name || "SecretSyncError";
this.error = error;
this.secretKey = secretKey;
this.shouldRetry = shouldRetry;
}
}

View File

@ -0,0 +1,127 @@
import { AxiosError } from "axios";
import {
AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
AwsParameterStoreSyncFns
} from "@app/services/secret-sync/aws-parameter-store";
import { GITHUB_SYNC_LIST_OPTION, GithubSyncFns } from "@app/services/secret-sync/github";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import {
TSecretMap,
TSecretSyncListItem,
TSecretSyncWithCredentials
} from "@app/services/secret-sync/secret-sync-types";
const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
[SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION
};
export const listSecretSyncOptions = () => {
return Object.values(SECRET_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
};
// const addAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => {
// let secretMap = { ...unprocessedSecretMap };
//
// const { appendSuffix, prependPrefix } = secretSync.syncOptions;
//
// if (appendSuffix || prependPrefix) {
// secretMap = {};
// Object.entries(unprocessedSecretMap).forEach(([key, value]) => {
// secretMap[`${prependPrefix || ""}${key}${appendSuffix || ""}`] = value;
// });
// }
//
// return secretMap;
// };
//
// const stripAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => {
// let secretMap = { ...unprocessedSecretMap };
//
// const { appendSuffix, prependPrefix } = secretSync.syncOptions;
//
// if (appendSuffix || prependPrefix) {
// secretMap = {};
// Object.entries(unprocessedSecretMap).forEach(([key, value]) => {
// let processedKey = key;
//
// if (prependPrefix && processedKey.startsWith(prependPrefix)) {
// processedKey = processedKey.slice(prependPrefix.length);
// }
//
// if (appendSuffix && processedKey.endsWith(appendSuffix)) {
// processedKey = processedKey.slice(0, -appendSuffix.length);
// }
//
// secretMap[processedKey] = value;
// });
// }
//
// return secretMap;
// };
export const SecretSyncFns = {
syncSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => {
// const affixedSecretMap = addAffixes(secretSync, secretMap);
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
return AwsParameterStoreSyncFns.syncSecrets(secretSync, secretMap);
case SecretSync.GitHub:
return GithubSyncFns.syncSecrets(secretSync, secretMap);
default:
throw new Error(
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
);
}
},
getSecrets: async (secretSync: TSecretSyncWithCredentials): Promise<TSecretMap> => {
let secretMap: TSecretMap;
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
secretMap = await AwsParameterStoreSyncFns.getSecrets(secretSync);
break;
case SecretSync.GitHub:
secretMap = await GithubSyncFns.getSecrets(secretSync);
break;
default:
throw new Error(
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
);
}
return secretMap;
// return stripAffixes(secretSync, secretMap);
},
removeSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => {
// const affixedSecretMap = addAffixes(secretSync, secretMap);
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
return AwsParameterStoreSyncFns.removeSecrets(secretSync, secretMap);
case SecretSync.GitHub:
return GithubSyncFns.removeSecrets(secretSync, secretMap);
default:
throw new Error(
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
);
}
}
};
export const parseSyncErrorMessage = (err: unknown): string => {
if (err instanceof SecretSyncError) {
return JSON.stringify({
secretKey: err.secretKey,
error: err.message ?? parseSyncErrorMessage(err.error)
});
}
if (err instanceof AxiosError) {
return err?.response?.data ? JSON.stringify(err?.response?.data) : err?.message ?? "An unknown error occurred.";
}
return (err as Error)?.message || "An unknown error occurred.";
};

View File

@ -0,0 +1,12 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
[SecretSync.GitHub]: "GitHub"
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.AWSParameterStore]: AppConnection.AWS,
[SecretSync.GitHub]: AppConnection.GitHub
};

View File

@ -0,0 +1,955 @@
import opentelemetry from "@opentelemetry/api";
import { AxiosError } from "axios";
import { Job } from "bullmq";
import { ProjectMembershipRole, SecretType } from "@app/db/schemas";
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@app/services/secret/secret-fns";
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
import { fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns";
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
import {
SecretSync,
SecretSyncImportBehavior,
SecretSyncInitialSyncBehavior
} from "@app/services/secret-sync/secret-sync-enums";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns";
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
import {
SecretSyncAction,
SecretSyncStatus,
TQueueSecretSyncImportSecretsByIdDTO,
TQueueSecretSyncRemoveSecretsByIdDTO,
TQueueSecretSyncsByPathDTO,
TQueueSecretSyncSyncSecretsByIdDTO,
TQueueSendSecretSyncActionFailedNotificationsDTO,
TSecretMap,
TSecretSyncImportSecretsDTO,
TSecretSyncRaw,
TSecretSyncRemoveSecretsDTO,
TSecretSyncSyncSecretsDTO,
TSecretSyncWithCredentials,
TSendSecretSyncFailedNotificationsJobDTO
} from "@app/services/secret-sync/secret-sync-types";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
import { expandSecretReferencesFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
type TSecretSyncQueueFactoryDep = {
queueService: Pick<TQueueServiceFactory, "queue" | "start">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
folderDAL: TSecretFolderDALFactory;
secretV2BridgeDAL: Pick<
TSecretV2BridgeDALFactory,
| "findByFolderId"
| "find"
| "insertMany"
| "upsertSecretReferences"
| "findBySecretKeys"
| "bulkUpdate"
| "deleteMany"
>;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: TProjectDALFactory;
smtpService: Pick<TSmtpService, "sendMail">;
projectBotDAL: TProjectBotDALFactory;
secretDAL: TSecretDALFactory;
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
secretVersionTagDAL: TSecretVersionTagDALFactory;
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
};
type SecretSyncActionJob = Job<
TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO
>;
const getRequeueDelay = (failureCount?: number) => {
if (!failureCount) return 0;
const baseDelay = 1000;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5);
return jitter;
};
export const secretSyncQueueFactory = ({
queueService,
kmsService,
keyStore,
folderDAL,
secretV2BridgeDAL,
secretImportDAL,
secretSyncDAL,
auditLogService,
projectMembershipDAL,
projectDAL,
smtpService,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
secretVersionV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
}: TSecretSyncQueueFactoryDep) => {
const appCfg = getConfig();
const integrationMeter = opentelemetry.metrics.getMeter("SecretSyncs");
const syncSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_sync_secrets_errors", {
description: "Secret Sync - sync secrets errors",
unit: "1"
});
const importSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_import_secrets_errors", {
description: "Secret Sync - import secrets errors",
unit: "1"
});
const removeSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_remove_secrets_errors", {
description: "Secret Sync - remove secrets errors",
unit: "1"
});
const $createManySecretsRawFn = createManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
kmsService,
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
});
const $updateManySecretsRawFn = updateManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
kmsService,
secretVersionV2BridgeDAL,
secretV2BridgeDAL,
secretVersionTagV2BridgeDAL,
resourceMetadataDAL
});
const $getInfisicalSecrets = async (
secretSync: TSecretSyncRaw | TSecretSyncWithCredentials,
includeImports = true
) => {
const { projectId, folderId, environment, folder } = secretSync;
if (!folderId || !environment || !folder)
throw new SecretSyncError({
message:
"Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path.",
shouldRetry: false
});
const secretMap: TSecretMap = {};
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptSecretValue = (value?: Buffer | undefined | null) =>
value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "";
const { expandSecretReferences } = expandSecretReferencesFactory({
decryptSecretValue,
secretDAL: secretV2BridgeDAL,
folderDAL,
projectId,
canExpandValue: () => true
});
const secrets = await secretV2BridgeDAL.findByFolderId(folderId);
await Promise.allSettled(
secrets.map(async (secret) => {
const secretKey = secret.key;
const secretValue = decryptSecretValue(secret.encryptedValue);
const expandedSecretValue = await expandSecretReferences({
environment: environment.slug,
secretPath: folder.path,
skipMultilineEncoding: secret.skipMultilineEncoding,
value: secretValue
});
secretMap[secretKey] = { value: expandedSecretValue || "" };
if (secret.encryptedComment) {
const commentValue = decryptSecretValue(secret.encryptedComment);
secretMap[secretKey].comment = commentValue;
}
secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
})
);
if (!includeImports) return secretMap;
const secretImports = await secretImportDAL.find({ folderId, isReplication: false });
if (secretImports.length) {
const importedSecrets = await fnSecretsV2FromImports({
decryptor: decryptSecretValue,
folderDAL,
secretDAL: secretV2BridgeDAL,
expandSecretReferences,
secretImportDAL,
secretImports,
hasSecretAccess: () => true
});
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) {
const importedSecret = importedSecrets[i].secrets[j];
if (!secretMap[importedSecret.key]) {
secretMap[importedSecret.key] = {
skipMultilineEncoding: importedSecret.skipMultilineEncoding,
comment: importedSecret.secretComment,
value: importedSecret.secretValue || ""
};
}
}
}
}
return secretMap;
};
const queueSecretSyncSyncSecretsById = async (payload: TQueueSecretSyncSyncSecretsByIdDTO) =>
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncSyncSecrets, payload, {
delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked
attempts: 5,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: true,
removeOnFail: true
});
const queueSecretSyncImportSecretsById = async (payload: TQueueSecretSyncImportSecretsByIdDTO) =>
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncImportSecrets, payload, {
attempts: 1,
removeOnComplete: true,
removeOnFail: true
});
const queueSecretSyncRemoveSecretsById = async (payload: TQueueSecretSyncRemoveSecretsByIdDTO) =>
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncRemoveSecrets, payload, {
attempts: 1,
removeOnComplete: true,
removeOnFail: true
});
const $queueSendSecretSyncFailedNotifications = async (payload: TQueueSendSecretSyncActionFailedNotificationsDTO) => {
if (!appCfg.isSmtpConfigured) return;
await queueService.queue(
QueueName.AppConnectionSecretSync,
QueueJobs.SecretSyncSendActionFailedNotifications,
payload,
{
jobId: `secret-sync-${payload.secretSync.id}-failed-notifications`,
attempts: 5,
delay: 1000 * 60,
backoff: {
type: "exponential",
delay: 3000
},
removeOnFail: true,
removeOnComplete: true
}
);
};
const $importSecrets = async (
secretSync: TSecretSyncWithCredentials,
importBehavior: SecretSyncImportBehavior
): Promise<TSecretMap> => {
const { projectId, environment, folder } = secretSync;
if (!environment || !folder)
throw new Error(
"Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path."
);
const importedSecrets = await SecretSyncFns.getSecrets(secretSync);
if (!Object.keys(importedSecrets).length) return {};
const importedSecretMap: TSecretMap = {};
const secretMap = await $getInfisicalSecrets(secretSync, false);
const secretsToCreate: Parameters<typeof $createManySecretsRawFn>[0]["secrets"] = [];
const secretsToUpdate: Parameters<typeof $updateManySecretsRawFn>[0]["secrets"] = [];
Object.entries(importedSecrets).forEach(([key, secretData]) => {
const { value, comment = "", skipMultilineEncoding } = secretData;
const secret = {
secretName: key,
secretValue: value,
type: SecretType.Shared,
secretComment: comment,
skipMultilineEncoding: skipMultilineEncoding ?? undefined
};
if (Object.hasOwn(secretMap, key)) {
secretsToUpdate.push(secret);
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData;
} else {
secretsToCreate.push(secret);
importedSecretMap[key] = secretData;
}
});
if (secretsToCreate.length) {
await $createManySecretsRawFn({
projectId,
path: folder.path,
environment: environment.slug,
secrets: secretsToCreate
});
}
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination && secretsToUpdate.length) {
await $updateManySecretsRawFn({
projectId,
path: folder.path,
environment: environment.slug,
secrets: secretsToUpdate
});
}
return importedSecretMap;
};
const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => {
const {
data: { syncId, auditLogInfo }
} = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await secretSyncDAL.updateById(syncId, {
syncStatus: SecretSyncStatus.Running
});
logger.info(
`SecretSync Sync [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
let isSynced = false;
let syncMessage: string | null = null;
let isFinalAttempt = job.attemptsStarted === job.opts.attempts;
try {
const {
connection: { orgId, encryptedCredentials }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
});
const secretSyncWithCredentials = {
...secretSync,
connection: {
...secretSync.connection,
credentials
}
} as TSecretSyncWithCredentials;
const {
lastSyncedAt,
syncOptions: { initialSyncBehavior }
} = secretSyncWithCredentials;
const secretMap = await $getInfisicalSecrets(secretSync);
if (!lastSyncedAt && initialSyncBehavior !== SecretSyncInitialSyncBehavior.OverwriteDestination) {
const importedSecretMap = await $importSecrets(
secretSyncWithCredentials,
initialSyncBehavior === SecretSyncInitialSyncBehavior.ImportPrioritizeSource
? SecretSyncImportBehavior.PrioritizeSource
: SecretSyncImportBehavior.PrioritizeDestination
);
Object.entries(importedSecretMap).forEach(([key, secretData]) => {
secretMap[key] = secretData;
});
}
await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap);
isSynced = true;
} catch (err) {
logger.error(
err,
`SecretSync Sync Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
syncSecretsErrorHistogram.record(1, {
version: 1,
destination: secretSync.destination,
syncId: secretSync.id,
projectId: secretSync.projectId,
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
status: err instanceof AxiosError ? err.response?.status : undefined,
name: err instanceof Error ? err.name : undefined
});
}
syncMessage = parseSyncErrorMessage(err);
if (err instanceof SecretSyncError && !err.shouldRetry) {
isFinalAttempt = true;
} else {
// re-throw so job fails
throw err;
}
} finally {
const ranAt = new Date();
const syncStatus = isSynced ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
await auditLogService.createAuditLog({
projectId: secretSync.projectId,
...(auditLogInfo ?? {
actor: {
type: ActorType.PLATFORM,
metadata: {}
}
}),
event: {
type: EventType.SECRET_SYNC_SYNC_SECRETS,
metadata: {
syncId: secretSync.id,
syncOptions: secretSync.syncOptions,
destination: secretSync.destination,
destinationConfig: secretSync.destinationConfig,
folderId: secretSync.folderId,
connectionId: secretSync.connectionId,
jobRanAt: ranAt,
jobId: job.id!,
syncStatus,
syncMessage
}
}
});
if (isSynced || isFinalAttempt) {
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
syncStatus,
lastSyncJobId: job.id,
lastSyncMessage: syncMessage,
lastSyncedAt: isSynced ? ranAt : undefined
});
if (!isSynced) {
await $queueSendSecretSyncFailedNotifications({
secretSync: updatedSecretSync,
action: SecretSyncAction.SyncSecrets,
auditLogInfo
});
}
}
}
logger.info("SecretSync Sync Job with ID %s Completed", job.id);
};
const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => {
const {
data: { syncId, auditLogInfo, importBehavior }
} = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await secretSyncDAL.updateById(syncId, {
importStatus: SecretSyncStatus.Running
});
logger.info(
`SecretSync Import [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
let isSuccess = false;
let importMessage: string | null = null;
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
try {
const {
connection: { orgId, encryptedCredentials }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
});
await $importSecrets(
{
...secretSync,
connection: {
...secretSync.connection,
credentials
}
} as TSecretSyncWithCredentials,
importBehavior
);
isSuccess = true;
} catch (err) {
logger.error(
err,
`SecretSync Import Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
importSecretsErrorHistogram.record(1, {
version: 1,
destination: secretSync.destination,
syncId: secretSync.id,
projectId: secretSync.projectId,
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
status: err instanceof AxiosError ? err.response?.status : undefined,
name: err instanceof Error ? err.name : undefined
});
}
importMessage = parseSyncErrorMessage(err);
// re-throw so job fails
throw err;
} finally {
const ranAt = new Date();
const importStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
await auditLogService.createAuditLog({
projectId: secretSync.projectId,
...(auditLogInfo ?? {
actor: {
type: ActorType.PLATFORM,
metadata: {}
}
}),
event: {
type: EventType.SECRET_SYNC_IMPORT_SECRETS,
metadata: {
syncId: secretSync.id,
syncOptions: secretSync.syncOptions,
destination: secretSync.destination,
destinationConfig: secretSync.destinationConfig,
folderId: secretSync.folderId,
connectionId: secretSync.connectionId,
jobRanAt: ranAt,
jobId: job.id!,
importStatus,
importMessage,
importBehavior
}
}
});
if (isSuccess || isFinalAttempt) {
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
importStatus,
lastImportJobId: job.id,
lastImportMessage: importMessage,
lastImportedAt: isSuccess ? ranAt : undefined
});
if (!isSuccess) {
await $queueSendSecretSyncFailedNotifications({
secretSync: updatedSecretSync,
action: SecretSyncAction.ImportSecrets,
auditLogInfo
});
}
}
}
logger.info("SecretSync Import Job with ID %s Completed", job.id);
};
const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => {
const {
data: { syncId, auditLogInfo, deleteSyncOnComplete }
} = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await secretSyncDAL.updateById(syncId, {
removeStatus: SecretSyncStatus.Running
});
logger.info(
`SecretSync Remove [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
let isSuccess = false;
let removeMessage: string | null = null;
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
try {
const {
connection: { orgId, encryptedCredentials }
} = secretSync;
const credentials = await decryptAppConnectionCredentials({
orgId,
encryptedCredentials,
kmsService
});
const secretMap = await $getInfisicalSecrets(secretSync);
await SecretSyncFns.removeSecrets(
{
...secretSync,
connection: {
...secretSync.connection,
credentials
}
} as TSecretSyncWithCredentials,
secretMap
);
isSuccess = true;
} catch (err) {
logger.error(
err,
`SecretSync Remove Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
removeSecretsErrorHistogram.record(1, {
version: 1,
destination: secretSync.destination,
syncId: secretSync.id,
projectId: secretSync.projectId,
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
status: err instanceof AxiosError ? err.response?.status : undefined,
name: err instanceof Error ? err.name : undefined
});
}
removeMessage = parseSyncErrorMessage(err);
// re-throw so job fails
throw err;
} finally {
const ranAt = new Date();
const removeStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
await auditLogService.createAuditLog({
projectId: secretSync.projectId,
...(auditLogInfo ?? {
actor: {
type: ActorType.PLATFORM,
metadata: {}
}
}),
event: {
type: EventType.SECRET_SYNC_REMOVE_SECRETS,
metadata: {
syncId: secretSync.id,
syncOptions: secretSync.syncOptions,
destination: secretSync.destination,
destinationConfig: secretSync.destinationConfig,
folderId: secretSync.folderId,
connectionId: secretSync.connectionId,
jobRanAt: ranAt,
jobId: job.id!,
removeStatus,
removeMessage
}
}
});
if (isSuccess || isFinalAttempt) {
if (isSuccess && deleteSyncOnComplete) {
await secretSyncDAL.deleteById(secretSync.id);
} else {
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
removeStatus,
lastRemoveJobId: job.id,
lastRemoveMessage: removeMessage,
lastRemovedAt: isSuccess ? ranAt : undefined
});
if (!isSuccess) {
await $queueSendSecretSyncFailedNotifications({
secretSync: updatedSecretSync,
action: SecretSyncAction.RemoveSecrets,
auditLogInfo
});
}
}
}
}
logger.info("SecretSync Remove Job with ID %s Completed", job.id);
};
const $sendSecretSyncFailedNotifications = async (job: TSendSecretSyncFailedNotificationsJobDTO) => {
const {
data: { secretSync, auditLogInfo, action }
} = job;
const { projectId, destination, name, folder, lastSyncMessage, lastRemoveMessage, lastImportMessage, environment } =
secretSync;
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
const project = await projectDAL.findById(projectId);
let projectAdmins = projectMembers.filter((member) =>
member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
);
const triggeredByUserId =
auditLogInfo && auditLogInfo.actor.type === ActorType.USER && auditLogInfo.actor.metadata.userId;
// only notify triggering user if triggered by admin
if (triggeredByUserId && projectAdmins.map((admin) => admin.userId).includes(triggeredByUserId)) {
projectAdmins = projectAdmins.filter((admin) => admin.userId === triggeredByUserId);
}
const syncDestination = SECRET_SYNC_NAME_MAP[destination as SecretSync];
let actionLabel: string;
let failureMessage: string | null | undefined;
switch (action) {
case SecretSyncAction.ImportSecrets:
actionLabel = "Import";
failureMessage = lastImportMessage;
break;
case SecretSyncAction.RemoveSecrets:
actionLabel = "Remove";
failureMessage = lastRemoveMessage;
break;
case SecretSyncAction.SyncSecrets:
default:
actionLabel = `Sync`;
failureMessage = lastSyncMessage;
break;
}
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretSyncFailed,
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
substitutions: {
syncName: name,
syncDestination,
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
failureMessage,
secretPath: folder?.path,
environment: environment?.name,
projectName: project.name,
syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}`
}
});
};
const queueSecretSyncsSyncSecretsByPath = async ({
secretPath,
projectId,
environmentSlug
}: TQueueSecretSyncsByPathDTO) => {
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath);
if (!folder)
throw new Error(
`Could not find folder at path "${secretPath}" for environment with slug "${environmentSlug}" in project with ID "${projectId}"`
);
const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true });
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id })));
};
const $handleAcquireLockFailure = async (job: SecretSyncActionJob) => {
const { syncId, auditLogInfo } = job.data;
switch (job.name) {
case QueueJobs.SecretSyncSyncSecrets: {
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO;
if (failedToAcquireLockCount < 10) {
await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
return;
}
const secretSync = await secretSyncDAL.updateById(syncId, {
syncStatus: SecretSyncStatus.Failed,
lastSyncMessage:
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
lastSyncJobId: job.id
});
await $queueSendSecretSyncFailedNotifications({
secretSync,
action: SecretSyncAction.SyncSecrets,
auditLogInfo
});
break;
}
// Scott: the two cases below are unlikely to happen as we check the lock at the API level but including this as a fallback
case QueueJobs.SecretSyncImportSecrets: {
const secretSync = await secretSyncDAL.updateById(syncId, {
importStatus: SecretSyncStatus.Failed,
lastImportMessage:
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
lastImportJobId: job.id
});
await $queueSendSecretSyncFailedNotifications({
secretSync,
action: SecretSyncAction.ImportSecrets,
auditLogInfo
});
break;
}
case QueueJobs.SecretSyncRemoveSecrets: {
const secretSync = await secretSyncDAL.updateById(syncId, {
removeStatus: SecretSyncStatus.Failed,
lastRemoveMessage:
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
lastRemoveJobId: job.id
});
await $queueSendSecretSyncFailedNotifications({
secretSync,
action: SecretSyncAction.RemoveSecrets,
auditLogInfo
});
break;
}
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled Secret Sync Job ${job.name}`);
}
};
queueService.start(QueueName.AppConnectionSecretSync, async (job) => {
if (job.name === QueueJobs.SecretSyncSendActionFailedNotifications) {
await $sendSecretSyncFailedNotifications(job as TSendSecretSyncFailedNotificationsJobDTO);
return;
}
const { syncId } = job.data as
| TQueueSecretSyncSyncSecretsByIdDTO
| TQueueSecretSyncImportSecretsByIdDTO
| TQueueSecretSyncRemoveSecretsByIdDTO;
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try {
lock = await keyStore.acquireLock(
[KeyStorePrefixes.SecretSyncLock(syncId)],
// scott: not sure on this duration; syncs can take excessive amounts of time so we need to keep it locked,
// but should always release below...
5 * 60 * 1000
);
} catch (e) {
logger.info(`SecretSync Failed to acquire lock [syncId=${syncId}] [job=${job.name}]`);
await $handleAcquireLockFailure(job as SecretSyncActionJob);
return;
}
try {
switch (job.name) {
case QueueJobs.SecretSyncSyncSecrets:
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO);
break;
case QueueJobs.SecretSyncImportSecrets:
await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO);
break;
case QueueJobs.SecretSyncRemoveSecrets:
await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO);
break;
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled Secret Sync Job ${job.name}`);
}
} finally {
await lock.release();
}
});
return {
queueSecretSyncSyncSecretsById,
queueSecretSyncImportSecretsById,
queueSecretSyncRemoveSecretsById,
queueSecretSyncsSyncSecretsByPath
};
};

View File

@ -0,0 +1,96 @@
import { z } from "zod";
import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs";
import { SecretSyncs } from "@app/lib/api-docs";
import { removeTrailingSlash } from "@app/lib/fn";
import { slugSchema } from "@app/server/lib/schemas";
import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums";
import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) =>
z.object({
initialSyncBehavior: (options.canImportSecrets
? z.nativeEnum(SecretSyncInitialSyncBehavior)
: z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination)
).describe(SecretSyncs.SYNC_OPTIONS(secretSync).INITIAL_SYNC_BEHAVIOR)
// prependPrefix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX),
// appendSuffix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX)
});
export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
SecretSyncsSchema.omit({
destination: true,
destinationConfig: true,
syncOptions: true
}).extend({
// destination needs to be on the extended object for type differentiation
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig),
// join properties
projectId: z.string(),
connection: z.object({
app: z.literal(SECRET_SYNC_CONNECTION_MAP[destination]),
name: z.string(),
id: z.string().uuid()
}),
environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }).nullable(),
folder: z.object({ id: z.string(), path: z.string() }).nullable()
});
export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name),
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(SecretSyncs.CREATE(destination).description),
connectionId: z.string().uuid().describe(SecretSyncs.CREATE(destination).connectionId),
environment: slugSchema({ field: "environment", max: 64 }).describe(SecretSyncs.CREATE(destination).environment),
secretPath: z
.string()
.trim()
.min(1, "Secret path required")
.transform(removeTrailingSlash)
.describe(SecretSyncs.CREATE(destination).secretPath),
isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled),
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions)
});
export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(),
connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(SecretSyncs.UPDATE(destination).description),
environment: slugSchema({ field: "environment", max: 64 })
.optional()
.describe(SecretSyncs.UPDATE(destination).environment),
secretPath: z
.string()
.trim()
.min(1, "Invalid secret path")
.transform(removeTrailingSlash)
.optional()
.describe(SecretSyncs.UPDATE(destination).secretPath),
isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled),
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig)
.optional()
.describe(SecretSyncs.UPDATE(destination).syncOptions)
});

View File

@ -0,0 +1,562 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSecretSyncActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
import {
SecretSyncStatus,
TCreateSecretSyncDTO,
TDeleteSecretSyncDTO,
TFindSecretSyncByIdDTO,
TFindSecretSyncByNameDTO,
TListSecretSyncsByProjectId,
TSecretSync,
TTriggerSecretSyncImportSecretsByIdDTO,
TTriggerSecretSyncRemoveSecretsByIdDTO,
TTriggerSecretSyncSyncSecretsByIdDTO,
TUpdateSecretSyncDTO
} from "@app/services/secret-sync/secret-sync-types";
import { TSecretSyncDALFactory } from "./secret-sync-dal";
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
type TSecretSyncServiceFactoryDep = {
secretSyncDAL: TSecretSyncDALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
keyStore: Pick<TKeyStoreFactory, "getItem">;
secretSyncQueue: Pick<
TSecretSyncQueueFactory,
"queueSecretSyncSyncSecretsById" | "queueSecretSyncImportSecretsById" | "queueSecretSyncRemoveSecretsById"
>;
};
export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFactory>;
export const secretSyncServiceFactory = ({
secretSyncDAL,
folderDAL,
permissionService,
appConnectionService,
projectBotService,
secretSyncQueue,
keyStore
}: TSecretSyncServiceFactoryDep) => {
const listSecretSyncsByProjectId = async (
{ projectId, destination }: TListSecretSyncsByProjectId,
actor: OrgServiceActor
) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Read,
ProjectPermissionSub.SecretSyncs
);
const secretSyncs = await secretSyncDAL.find({
...(destination && { destination }),
projectId
});
return secretSyncs as TSecretSync[];
};
const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => {
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Read,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
return secretSync as TSecretSync;
};
const findSecretSyncByName = async (
{ destination, syncName, projectId }: TFindSecretSyncByNameDTO,
actor: OrgServiceActor
) => {
const folders = await folderDAL.findByProjectId(projectId);
// we prevent conflicting names within a project so this will only return one at most
const [secretSync] = await secretSyncDAL.find({
name: syncName,
$in: {
folderId: folders.map((folder) => folder.id)
}
});
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with name "${syncName}"`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Read,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
return secretSync as TSecretSync;
};
const createSecretSync = async (
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
actor: OrgServiceActor
) => {
const { permission: projectPermission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId
});
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({ message: "Project version does not support Secret Syncs" });
ForbiddenError.from(projectPermission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Create,
ProjectPermissionSub.SecretSyncs
);
ForbiddenError.from(projectPermission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath
})
);
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder)
throw new BadRequestError({
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
});
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
// validates permission to connect and app is valid for sync destination
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
const secretSync = await secretSyncDAL.transaction(async (tx) => {
const isConflictingName = Boolean(
(
await secretSyncDAL.find(
{
name: params.name,
projectId
},
tx
)
).length
);
if (isConflictingName)
throw new BadRequestError({
message: `A Secret Sync with the name "${params.name}" already exists for the project with ID "${folder.projectId}"`
});
const sync = await secretSyncDAL.create({
folderId: folder.id,
...params,
...(params.isAutoSyncEnabled && { syncStatus: SecretSyncStatus.Pending }),
projectId
});
return sync;
});
if (secretSync.isAutoSyncEnabled) await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id });
return secretSync as TSecretSync;
};
const updateSecretSync = async (
{ destination, syncId, secretPath, environment, ...params }: TUpdateSecretSyncDTO,
actor: OrgServiceActor
) => {
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID ${syncId}`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Edit,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
const updatedSecretSync = await secretSyncDAL.transaction(async (tx) => {
let { folderId } = secretSync;
if (params.connectionId) {
const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];
// validates permission to connect and app is valid for sync destination
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
}
if (
(secretPath && secretPath !== secretSync.folder?.path) ||
(environment && environment !== secretSync.environment?.slug)
) {
const updatedEnvironment = environment ?? secretSync.environment?.slug;
const updatedSecretPath = secretPath ?? secretSync.folder?.path;
if (!updatedEnvironment || !updatedSecretPath)
throw new BadRequestError({ message: "Must specify both source environment and secret path" });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: updatedEnvironment,
secretPath: updatedSecretPath
})
);
const newFolder = await folderDAL.findBySecretPath(secretSync.projectId, updatedEnvironment, updatedSecretPath);
if (!newFolder)
throw new BadRequestError({
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${secretSync.projectId}"`
});
folderId = newFolder.id;
}
if (params.name && secretSync.name !== params.name) {
const isConflictingName = Boolean(
(
await secretSyncDAL.find(
{
name: params.name,
projectId: secretSync.projectId
},
tx
)
).length
);
if (isConflictingName)
throw new BadRequestError({
message: `A Secret Sync with the name "${params.name}" already exists for project with ID "${secretSync.projectId}"`
});
}
const isAutoSyncEnabled = params.isAutoSyncEnabled ?? secretSync.isAutoSyncEnabled;
const updatedSync = await secretSyncDAL.updateById(syncId, {
...params,
...(isAutoSyncEnabled && folderId && { syncStatus: SecretSyncStatus.Pending }),
folderId
});
return updatedSync;
});
if (updatedSecretSync.isAutoSyncEnabled)
await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id });
return updatedSecretSync as TSecretSync;
};
const deleteSecretSync = async (
{ destination, syncId, removeSecrets }: TDeleteSecretSyncDTO,
actor: OrgServiceActor
) => {
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Delete,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
if (removeSecrets) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.RemoveSecrets,
ProjectPermissionSub.SecretSyncs
);
if (!secretSync.folderId)
throw new BadRequestError({
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
});
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
if (isSyncJobRunning)
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, deleteSyncOnComplete: true });
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
removeStatus: SecretSyncStatus.Pending
});
return updatedSecretSync;
}
await secretSyncDAL.deleteById(syncId);
return secretSync as TSecretSync;
};
const triggerSecretSyncSyncSecretsById = async (
{ syncId, destination, ...params }: TTriggerSecretSyncSyncSecretsByIdDTO,
actor: OrgServiceActor
) => {
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.SyncSecrets,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
if (!secretSync.folderId)
throw new BadRequestError({
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
});
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
if (isSyncJobRunning)
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId, ...params });
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
syncStatus: SecretSyncStatus.Pending
});
return updatedSecretSync as TSecretSync;
};
const triggerSecretSyncImportSecretsById = async (
{ syncId, destination, ...params }: TTriggerSecretSyncImportSecretsByIdDTO,
actor: OrgServiceActor
) => {
if (!listSecretSyncOptions().find((option) => option.destination === destination)?.canImportSecrets) {
throw new BadRequestError({
message: `${SECRET_SYNC_NAME_MAP[destination]} does not support importing secrets.`
});
}
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.ImportSecrets,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
if (!secretSync.folderId)
throw new BadRequestError({
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
});
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
if (isSyncJobRunning)
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
await secretSyncQueue.queueSecretSyncImportSecretsById({ syncId, ...params });
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
importStatus: SecretSyncStatus.Pending
});
return updatedSecretSync as TSecretSync;
};
const triggerSecretSyncRemoveSecretsById = async (
{ syncId, destination, ...params }: TTriggerSecretSyncRemoveSecretsByIdDTO,
actor: OrgServiceActor
) => {
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync)
throw new NotFoundError({
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
});
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId: secretSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.RemoveSecrets,
ProjectPermissionSub.SecretSyncs
);
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
throw new BadRequestError({
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
});
if (!secretSync.folderId)
throw new BadRequestError({
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
});
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
if (isSyncJobRunning)
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, ...params });
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
removeStatus: SecretSyncStatus.Pending
});
return updatedSecretSync as TSecretSync;
};
return {
listSecretSyncOptions,
listSecretSyncsByProjectId,
findSecretSyncById,
findSecretSyncByName,
createSecretSync,
updateSecretSync,
deleteSecretSync,
triggerSecretSyncSyncSecretsById,
triggerSecretSyncImportSecretsById,
triggerSecretSyncRemoveSecretsById
};
};

View File

@ -0,0 +1,148 @@
import { Job } from "bullmq";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue";
import {
TGitHubSync,
TGitHubSyncInput,
TGitHubSyncListItem,
TGitHubSyncWithCredentials
} from "@app/services/secret-sync/github";
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
import {
TAwsParameterStoreSync,
TAwsParameterStoreSyncInput,
TAwsParameterStoreSyncListItem,
TAwsParameterStoreSyncWithCredentials
} from "./aws-parameter-store";
export type TSecretSync = TAwsParameterStoreSync | TGitHubSync;
export type TSecretSyncWithCredentials = TAwsParameterStoreSyncWithCredentials | TGitHubSyncWithCredentials;
export type TSecretSyncInput = TAwsParameterStoreSyncInput | TGitHubSyncInput;
export type TSecretSyncListItem = TAwsParameterStoreSyncListItem | TGitHubSyncListItem;
export type TSyncOptionsConfig = {
canImportSecrets: boolean;
};
export type TListSecretSyncsByProjectId = {
projectId: string;
destination?: SecretSync;
};
export type TFindSecretSyncByIdDTO = {
syncId: string;
destination: SecretSync;
};
export type TFindSecretSyncByNameDTO = {
syncName: string;
projectId: string;
destination: SecretSync;
};
export type TCreateSecretSyncDTO = Pick<TSecretSync, "syncOptions" | "destinationConfig" | "name" | "connectionId"> & {
destination: SecretSync;
projectId: string;
secretPath: string;
environment: string;
isAutoSyncEnabled?: boolean;
};
export type TUpdateSecretSyncDTO = Partial<Omit<TCreateSecretSyncDTO, "projectId">> & {
syncId: string;
destination: SecretSync;
};
export type TDeleteSecretSyncDTO = {
destination: SecretSync;
syncId: string;
removeSecrets: boolean;
};
type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">;
export enum SecretSyncStatus {
Pending = "pending",
Running = "running",
Succeeded = "succeeded",
Failed = "failed"
}
export enum SecretSyncAction {
SyncSecrets = "sync-secrets",
ImportSecrets = "import-secrets",
RemoveSecrets = "remove-secrets"
}
export type TSecretSyncRaw = NonNullable<Awaited<ReturnType<TSecretSyncDALFactory["findById"]>>>;
export type TQueueSecretSyncsByPathDTO = {
secretPath: string;
environmentSlug: string;
projectId: string;
};
export type TQueueSecretSyncSyncSecretsByIdDTO = {
syncId: string;
failedToAcquireLockCount?: number;
auditLogInfo?: AuditLogInfo;
};
export type TTriggerSecretSyncSyncSecretsByIdDTO = {
destination: SecretSync;
} & TQueueSecretSyncSyncSecretsByIdDTO;
export type TQueueSecretSyncImportSecretsByIdDTO = {
syncId: string;
importBehavior: SecretSyncImportBehavior;
auditLogInfo?: AuditLogInfo;
};
export type TTriggerSecretSyncImportSecretsByIdDTO = {
destination: SecretSync;
} & TQueueSecretSyncImportSecretsByIdDTO;
export type TQueueSecretSyncRemoveSecretsByIdDTO = {
syncId: string;
auditLogInfo?: AuditLogInfo;
deleteSyncOnComplete?: boolean;
};
export type TTriggerSecretSyncRemoveSecretsByIdDTO = {
destination: SecretSync;
} & TQueueSecretSyncRemoveSecretsByIdDTO;
export type TQueueSendSecretSyncActionFailedNotificationsDTO = {
secretSync: TSecretSyncRaw;
auditLogInfo?: AuditLogInfo;
action: SecretSyncAction;
};
export type TSecretSyncSyncSecretsDTO = Job<TQueueSecretSyncSyncSecretsByIdDTO, void, QueueJobs.SecretSyncSyncSecrets>;
export type TSecretSyncImportSecretsDTO = Job<
TQueueSecretSyncImportSecretsByIdDTO,
void,
QueueJobs.SecretSyncSyncSecrets
>;
export type TSecretSyncRemoveSecretsDTO = Job<
TQueueSecretSyncRemoveSecretsByIdDTO,
void,
QueueJobs.SecretSyncSyncSecrets
>;
export type TSendSecretSyncFailedNotificationsJobDTO = Job<
TQueueSendSecretSyncActionFailedNotificationsDTO,
void,
QueueJobs.SecretSyncSendActionFailedNotifications
>;
export type TSecretMap = Record<
string,
{ value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined }
>;

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";
@ -107,6 +108,7 @@ type TSecretQueueFactoryDep = {
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
};
export type TGetSecrets = {
@ -166,7 +168,8 @@ export const secretQueueFactory = ({
orgService,
projectUserMembershipRoleDAL,
projectKeyDAL,
resourceMetadataDAL
resourceMetadataDAL,
secretSyncQueue
}: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@ -633,6 +636,9 @@ export const secretQueueFactory = ({
}
}
);
await secretSyncQueue.queueSecretSyncsSyncSecretsByPath({ projectId, environmentSlug: environment, secretPath });
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
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,39 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{{syncDestination}} Sync "{{syncName}}" Failed</title>
</head>
<body>
<h2>Infisical</h2>
<div>
<p>{{content}}</p>
<a href="{{syncUrl}}">
View in Infisical.
</a>
</div>
<br />
<div>
<p><strong>Name</strong>: {{syncName}}</p>
<p><strong>Destination</strong>: {{syncDestination}}</p>
<p><strong>Project</strong>: {{projectName}}</p>
{{#if environment}}
<p><strong>Environment</strong>: {{environment}}</p>
{{/if}}
{{#if secretPath}}
<p><strong>Secret Path</strong>: {{secretPath}}</p>
{{/if}}
</div>
{{#if failureMessage}}
<p><b>Reason: </b>{{failureMessage}}</p>
{{/if}}
{{emailFooter}}
</body>
</html>

View File

@ -315,7 +315,11 @@ var loginCmd = &cobra.Command{
credential, err := authStrategies[strategy](cmd, infisicalClient)
if err != nil {
util.HandleError(fmt.Errorf("unable to authenticate with %s [err=%v]", formatAuthMethod(loginMethod), err))
euErrorMessage := ""
if strings.HasPrefix(config.INFISICAL_URL, util.INFISICAL_DEFAULT_US_URL) {
euErrorMessage = fmt.Sprintf("\nIf you are using the Infisical Cloud Europe Region, please switch to it by using the \"--domain %s\" flag.", util.INFISICAL_DEFAULT_EU_URL)
}
util.HandleError(fmt.Errorf("unable to authenticate with %s [err=%v].%s", formatAuthMethod(loginMethod), err, euErrorMessage))
}
if plainOutput {

View File

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

View File

@ -1,4 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/app-connections/aws/name/{connectionName}"
openapi: "GET /api/v1/app-connections/aws/connection-name/{connectionName}"
---

View File

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

View File

@ -1,4 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/app-connections/github/name/{connectionName}"
openapi: "GET /api/v1/app-connections/github/connection-name/{connectionName}"
---

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: "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/sync-name/{syncName}"
---

View File

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

View File

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

View File

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

View File

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

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: "Create"
openapi: "POST /api/v1/secret-syncs/github"
---

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
---
title: "Remove Secrets"
openapi: "POST /api/v1/secret-syncs/github/{syncId}/remove-secrets"
---

View File

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

View File

@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/secret-syncs/github/{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

@ -11,6 +11,7 @@ description: "Infisical CLI command overview"
| `init` | Used to link a local project to the platform. |
| `run` | Used to inject envars from the platform into an application process. |
| `vault` | Used to manage where your login credentials are stored at rest |
## Global options
| Option | Description |

View File

@ -5,7 +5,7 @@ title: "Node"
This guide demonstrates how to use Infisical to manage secrets for your Node stack from local development to production. It uses:
- Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets.
- The [@infisical/sdk](https://github.com/Infisical/sdk/tree/main/languages/node) Node.js client SDK to fetch secrets back to your Node application on demand.
- The [@infisical/sdk](https://github.com/Infisical/node-sdk-v2) Node.js client SDK to fetch secrets back to your Node application on demand.
## Project Setup
@ -46,43 +46,57 @@ Finally, create an index.js file containing the application code.
```js
const express = require('express');
const { InfisicalClient } = require("@infisical/sdk");
const { InfisicalSDK } = require("@infisical/sdk");
const app = express();
const PORT = 3000;
const client = new InfisicalClient({
auth: {
universalAuth: {
clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET",
}
}
});
let client;
const setupClient = () => {
if (client) {
return;
}
const infisicalSdk = new InfisicalSDK({
siteUrl: "your-infisical-instance.com" // Optional, defaults to https://app.infisical.com
});
await infisicalSdk.auth().universalAuth.login({
clientId: "<machine-identity-client-id>",
clientSecret: "<machine-identity-client-secret>"
});
// If authentication was successful, assign the client
client = infisicalSdk;
}
app.get("/", async (req, res) => {
// access value
const name = await client.getSecret({
environment: "dev",
projectId: "PROJECT_ID",
path: "/",
type: "shared",
secretName: "NAME"
const name = await client.secrets().getSecret({
environment: "dev", // dev, staging, prod, etc.
projectId: "<project-id>",
secretPath: "/",
secretName: "NAME"
});
res.send(`Hello! My name is: ${name.secretValue}`);
});
app.listen(PORT, async () => {
// initialize client
console.log(`App listening on port ${PORT}`);
// initialize http server and Infisical
await setupClient();
console.log(`Server listening on port ${PORT}`);
});
```
Here, we initialized a `client` instance of the Infisical Node SDK with the Infisical Token
Here, we initialized a `client` instance of the Infisical Node SDK with the [Machine Identity](/documentation/platform/identities/overview)
that we created earlier, giving access to the secrets in the development environment of the
project in Infisical that we created earlier.
@ -94,16 +108,12 @@ node index.js
The client fetched the secret with the key `NAME` from Infisical that we returned in the response of the endpoint.
At this stage, you know how to fetch secrets from Infisical back to your Node application. By using Infisical Tokens scoped to different environments, you can easily manage secrets across various stages of your project in Infisical, from local development to production.
At this stage, you know how to fetch secrets from Infisical back to your Node application.
By using Machine Identities scoped to different projects and environments, you can easily manage secrets across various stages of your project in Infisical, from local development to production.
## FAQ
<AccordionGroup>
<Accordion title="Isn't it inefficient if my app makes a request every time it needs a secret?">
The client SDK caches every secret and implements a 5-minute waiting period before
re-requesting it. The waiting period can be controlled by setting the `cacheTTL` parameter at
the time of initializing the client.
</Accordion>
<Accordion title="What if a request for a secret fails?">
The SDK caches every secret and falls back to the cached value if a request fails. If no cached
value ever-existed, the SDK falls back to whatever value is on `process.env`.
@ -124,4 +134,4 @@ At this stage, you know how to fetch secrets from Infisical back to your Node ap
See also:
- Explore the [Node SDK](https://github.com/Infisical/sdk/tree/main/languages/node)
- Explore the [Node SDK](https://github.com/Infisical/node-sdk-v2)

View File

@ -5,7 +5,7 @@ title: "Python"
This guide demonstrates how to use Infisical to manage secrets for your Python stack from local development to production. It uses:
- Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets.
- The [infisical-python](https://pypi.org/project/infisical-python/) Python client SDK to fetch secrets back to your Python application on demand.
- The [infisicalsdk](https://pypi.org/project/infisicalsdk/) Python client SDK to fetch secrets back to your Python application on demand.
## Project Setup
@ -36,40 +36,38 @@ python3 -m venv env
source env/bin/activate
```
Install Flask and [infisical-python](https://pypi.org/project/infisical-python/), the client Python SDK for Infisical.
Install Flask and [infisicalsdk](https://pypi.org/project/infisicalsdk/), the client Python SDK for Infisical.
```console
pip install flask infisical-python
pip install flask infisicalsdk
```
Finally, create an `app.py` file containing the application code.
```py
from flask import Flask
from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod
from infisical_sdk import InfisicalSDKClient
app = Flask(__name__)
client = InfisicalClient(ClientSettings(
auth=AuthenticationOptions(
universal_auth=UniversalAuthMethod(
client_id="CLIENT_ID",
client_secret="CLIENT_SECRET",
)
)
))
client = InfisicalSDKClient(host="https://app.infisical.com") # host is optional, defaults to https://app.infisical.com
client.auth.universal_auth.login(
"<machine-identity-client-id>",
"<machine-identity-client-secret>"
)
@app.route("/")
def hello_world():
# access value
name = client.secrets.get_secret_by_name(
secret_name="NAME",
project_id="<project-id>",
environment_slug="dev",
secret_path="/"
)
name = client.getSecret(options=GetSecretOptions(
environment="dev",
project_id="PROJECT_ID",
secret_name="NAME"
))
return f"Hello! My name is: {name.secret_value}"
return f"Hello! My name is: {name.secretValue}"
```
Here, we initialized a `client` instance of the Infisical Python SDK with the Infisical Token
@ -89,15 +87,6 @@ At this stage, you know how to fetch secrets from Infisical back to your Python
## FAQ
<AccordionGroup>
<Accordion title="Isn't it inefficient if my app makes a request every time it needs a secret?">
The client SDK caches every secret and implements a 5-minute waiting period before
re-requesting it. The waiting period can be controlled by setting the `cacheTTL` parameter at
the time of initializing the client.
</Accordion>
<Accordion title="What if a request for a secret fails?">
The SDK caches every secret and falls back to the cached value if a request fails. If no cached
value ever-existed, the SDK falls back to whatever value is on `process.env`.
</Accordion>
<Accordion title="What's the point if I still have to manage a token for the SDK?">
The token enables the SDK to authenticate with Infisical to fetch back your secrets.
Although the SDK requires you to pass in a token, it enables greater efficiency and security
@ -114,6 +103,6 @@ At this stage, you know how to fetch secrets from Infisical back to your Python
See also:
- Explore the [Python SDK](https://github.com/Infisical/sdk/tree/main/crates/infisical-py)
- Explore the [Python SDK](https://github.com/Infisical/python-sdk-official)

View File

@ -3,7 +3,7 @@ title: "Role-based Access Controls"
description: "Learn how to use RBAC to manage user permissions."
---
Infisical's Role-based Access Controls (RBAC) enable the usage of predefined and custom roles that imply a set of permissions for user and machine identities. Such roles male it possible to restrict access to resources and the range of actions that can be performed.
Infisical's Role-based Access Controls (RBAC) enable the usage of predefined and custom roles that imply a set of permissions for user and machine identities. Such roles make it possible to restrict access to resources and the range of actions that can be performed.
In general, access controls can be split up across [projects](/documentation/platform/project) and [organizations](/documentation/platform/organization).

View File

@ -48,7 +48,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO."
Back in the **Set up Single Sign-On with SAML** screen, select **Edit** in the **Attributes & Claims** section and configure the following map:
- `email -> user.userprinciplename`
- `email -> user.userprincipalname`
- `firstName -> user.givenname`
- `lastName -> user.surname`

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Some files were not shown because too many files have changed in this diff Show More