mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-31 22:09:57 +00:00
Compare commits
36 Commits
readme-ssh
...
correct-ap
Author | SHA1 | Date | |
---|---|---|---|
81420198cb | |||
0ff18e277f | |||
e093f70301 | |||
8e2ff18f35 | |||
3fbfecf7a9 | |||
9087def21c | |||
89c6ab591a | |||
235a33a01c | |||
dd6c217dc8 | |||
78b1b5583a | |||
8f2a504fd0 | |||
1d5b629d8f | |||
14f895cae2 | |||
b7be6bd1d9 | |||
58a97852f6 | |||
980aa9eaae | |||
a35d1aa72b | |||
c92c160709 | |||
71ca7a82db | |||
6f799b478d | |||
a89e6b6e58 | |||
99ca9e04f8 | |||
6cdc71b9b1 | |||
f88d6a183f | |||
fa82d4953e | |||
12d9fe9ffd | |||
3c1fc024c2 | |||
d627ecf05d | |||
6341b7e989 | |||
bc32d6cbbf | |||
0cf3115830 | |||
65f2e626ae | |||
8b3e3152a4 | |||
661b31f762 | |||
e78ad1147b | |||
473efa91f0 |
.github/workflows
backend/src
@types
db
ee
routes/v1
services
keystore
lib/api-docs
queue
server/routes
index.ts
v1
services
app-connection
secret-sync
aws-parameter-store
aws-parameter-store-sync-constants.tsaws-parameter-store-sync-fns.tsaws-parameter-store-sync-schemas.tsaws-parameter-store-sync-types.tsindex.ts
github
github-sync-constants.tsgithub-sync-enums.tsgithub-sync-fns.tsgithub-sync-schemas.tsgithub-sync-types.tsindex.ts
secret-sync-dal.tssecret-sync-enums.tssecret-sync-errors.tssecret-sync-fns.tssecret-sync-maps.tssecret-sync-queue.tssecret-sync-schemas.tssecret-sync-service.tssecret-sync-types.tssecret
smtp
cli/packages/cmd
docs
api-reference/endpoints
app-connections
secret-syncs
cli/commands
images
app-connections/aws
secret-syncs
integrations
mint.jsonfrontend/src
components
secret-syncs
CreateSecretSyncModal.tsxDeleteSecretSyncModal.tsxEditSecretSyncModal.tsxSecretSyncImportSecretsModal.tsxSecretSyncImportStatusBadge.tsxSecretSyncLabel.tsxSecretSyncModalHeader.tsxSecretSyncRemoveSecretsModal.tsxSecretSyncRemoveStatusBadge.tsxSecretSyncSelect.tsxSecretSyncStatusBadge.tsx
forms
CreateSecretSyncForm.tsxEditSecretSyncForm.tsxSecretSyncConnectionField.tsx
SecretSyncDestinationFields
SecretSyncDetailsFields.tsxSecretSyncOptionsFields.tsxSecretSyncReviewFields
SecretSyncSourceFields.tsxindex.tsschemas
github
index.tstypes
v2
const
context
helpers
hooks/api
appConnections
auditLogs
reactQuery.tsxsecretSyncs
subscriptions
layouts/OrganizationLayout
pages
organization
AppConnections/GithubOauthCallbackPage
RoleByIDPage/components
OrgRoleModifySection.utils.ts
RolePermissionsSection
SecretManagerOverviewPage
SettingsPage/components/AppConnectionsTab
project/RoleDetailsBySlugPage/components
secret-manager
IntegrationsListPage
IntegrationsListPage.tsxSecretSyncsTab.tsxindex.tsindex.tsroute.tsx
components
CloudIntegrationSection
FrameworkIntegrationSection
FrameworkIntegrationTab
InfrastructureIntegrationSection
InfrastructureIntegrationTab
IntegrationsSection
NativeIntegrationsTab
SecretSyncsTab
SecretSyncTable
SecretSyncDestinationCol
AwsParameterStoreSyncDestinationCol.tsxGitHubSyncDestinationCol.tsxSecretSyncDestinationCol.tsxindex.ts
SecretSyncRow.tsxSecretSyncTableCell.tsxSecretSyncsTable.tsxhelpers
index.tsSecretSyncDetailsByIDPage
SecretSyncDetailsByIDPage.tsx
components
SecretSyncActionTriggers.tsxSecretSyncAuditLogsSection.tsx
route.tsxSecretSyncDestinationSection
AwsParameterStoreSyncDestinationSection.tsxGitHubSyncDestinationSection.tsxSecretSyncDestinatonSection.tsxindex.ts
SecretSyncDetailsSection.tsxSecretSyncOptionsSection.tsxSecretSyncSourceSection.tsxindex.tsintegrations/VercelConfigurePage
types
2
.github/workflows/deployment-pipeline.yml
vendored
2
.github/workflows/deployment-pipeline.yml
vendored
@ -7,7 +7,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: "infisical-core-deployment"
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
infisical-tests:
|
||||
|
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@ -80,6 +80,7 @@ import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-
|
||||
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||
import { 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
|
||||
|
2
backend/src/@types/knex.d.ts
vendored
2
backend/src/@types/knex.d.ts
vendored
@ -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>;
|
||||
}
|
||||
}
|
||||
|
50
backend/src/db/migrations/20250122055102_secret-sync.ts
Normal file
50
backend/src/db/migrations/20250122055102_secret-sync.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.SecretSync))) {
|
||||
await knex.schema.createTable(TableName.SecretSync, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("name", 32).notNullable();
|
||||
t.string("description");
|
||||
t.string("destination").notNullable();
|
||||
t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true);
|
||||
t.integer("version").defaultTo(1).notNullable();
|
||||
t.jsonb("destinationConfig").notNullable();
|
||||
t.jsonb("syncOptions").notNullable();
|
||||
// we're including projectId in addition to folder ID because we allow folderId to be null (if the folder
|
||||
// is deleted), to preserve sync configuration
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.uuid("folderId");
|
||||
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("SET NULL");
|
||||
t.uuid("connectionId").notNullable();
|
||||
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
|
||||
t.timestamps(true, true, true);
|
||||
// sync secrets to destination
|
||||
t.string("syncStatus");
|
||||
t.string("lastSyncJobId");
|
||||
t.string("lastSyncMessage");
|
||||
t.datetime("lastSyncedAt");
|
||||
// import secrets from destination
|
||||
t.string("importStatus");
|
||||
t.string("lastImportJobId");
|
||||
t.string("lastImportMessage");
|
||||
t.datetime("lastImportedAt");
|
||||
// remove secrets from destination
|
||||
t.string("removeStatus");
|
||||
t.string("lastRemoveJobId");
|
||||
t.string("lastRemoveMessage");
|
||||
t.datetime("lastRemovedAt");
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.SecretSync);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.SecretSync);
|
||||
await dropOnUpdateTrigger(knex, TableName.SecretSync);
|
||||
}
|
@ -131,7 +131,8 @@ export enum TableName {
|
||||
WorkflowIntegrations = "workflow_integrations",
|
||||
SlackIntegrations = "slack_integrations",
|
||||
ProjectSlackConfigs = "project_slack_configs",
|
||||
AppConnection = "app_connections"
|
||||
AppConnection = "app_connections",
|
||||
SecretSync = "secret_syncs"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
|
||||
|
40
backend/src/db/schemas/secret-syncs.ts
Normal file
40
backend/src/db/schemas/secret-syncs.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SecretSyncsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
destination: z.string(),
|
||||
isAutoSyncEnabled: z.boolean().default(true),
|
||||
version: z.number().default(1),
|
||||
destinationConfig: z.unknown(),
|
||||
syncOptions: z.unknown(),
|
||||
projectId: z.string(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
connectionId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
syncStatus: z.string().nullable().optional(),
|
||||
lastSyncJobId: z.string().nullable().optional(),
|
||||
lastSyncMessage: z.string().nullable().optional(),
|
||||
lastSyncedAt: z.date().nullable().optional(),
|
||||
importStatus: z.string().nullable().optional(),
|
||||
lastImportJobId: z.string().nullable().optional(),
|
||||
lastImportMessage: z.string().nullable().optional(),
|
||||
lastImportedAt: z.date().nullable().optional(),
|
||||
removeStatus: z.string().nullable().optional(),
|
||||
lastRemoveJobId: z.string().nullable().optional(),
|
||||
lastRemoveMessage: z.string().nullable().optional(),
|
||||
lastRemovedAt: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretSyncs = z.infer<typeof SecretSyncsSchema>;
|
||||
export type TSecretSyncsInsert = Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>;
|
||||
export type TSecretSyncsUpdate = Partial<Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>>;
|
@ -24,6 +24,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
||||
),
|
||||
name: z.string().trim(),
|
||||
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: {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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) => {
|
||||
|
@ -68,7 +68,6 @@ export type TFeatureSet = {
|
||||
pkiEst: boolean;
|
||||
enforceMfa: boolean;
|
||||
projectTemplates: false;
|
||||
appConnections: false; // TODO: remove once live
|
||||
};
|
||||
|
||||
export type TOrgPlansTableDTO = {
|
||||
|
@ -1,4 +1,12 @@
|
||||
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";
|
||||
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CASL_ACTION_SCHEMA_ENUM,
|
||||
CASL_ACTION_SCHEMA_NATIVE_ENUM
|
||||
} from "@app/ee/services/permission/permission-schemas";
|
||||
import { PermissionConditionSchema } from "@app/ee/services/permission/permission-types";
|
||||
import { PermissionConditionOperators } from "@app/lib/casl";
|
||||
|
||||
export enum OrgPermissionActions {
|
||||
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;
|
||||
};
|
||||
|
9
backend/src/ee/services/permission/permission-schemas.ts
Normal file
9
backend/src/ee/services/permission/permission-schemas.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CASL_ACTION_SCHEMA_NATIVE_ENUM = <ACTION extends z.EnumLike>(actions: ACTION) =>
|
||||
z
|
||||
.union([z.nativeEnum(actions), z.nativeEnum(actions).array().min(1)])
|
||||
.transform((el) => (typeof el === "string" ? [el] : el));
|
||||
|
||||
export const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) =>
|
||||
z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el));
|
@ -1,6 +1,10 @@
|
||||
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
|
||||
import { 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;
|
||||
};
|
||||
|
@ -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
|
||||
};
|
||||
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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>;
|
||||
|
@ -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[] = [];
|
||||
|
@ -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]),
|
@ -1,17 +0,0 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
CreateGitHubConnectionSchema,
|
||||
SanitizedGitHubConnectionSchema,
|
||||
UpdateGitHubConnectionSchema
|
||||
} from "@app/services/app-connection/github";
|
||||
|
||||
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
||||
|
||||
export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) =>
|
||||
registerAppConnectionEndpoints({
|
||||
app: AppConnection.GitHub,
|
||||
server,
|
||||
responseSchema: SanitizedGitHubConnectionSchema,
|
||||
createSchema: CreateGitHubConnectionSchema,
|
||||
updateSchema: UpdateGitHubConnectionSchema
|
||||
});
|
@ -1,8 +0,0 @@
|
||||
import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router";
|
||||
import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
export const APP_CONNECTION_REGISTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[AppConnection.AWS]: registerAwsConnectionRouter,
|
||||
[AppConnection.GitHub]: registerGitHubConnectionRouter
|
||||
};
|
@ -11,7 +11,7 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
|
||||
registerAppConnectionEndpoints({
|
||||
app: AppConnection.AWS,
|
||||
server,
|
||||
responseSchema: SanitizedAwsConnectionSchema,
|
||||
sanitizedResponseSchema: SanitizedAwsConnectionSchema,
|
||||
createSchema: CreateAwsConnectionSchema,
|
||||
updateSchema: UpdateAwsConnectionSchema
|
||||
});
|
@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
CreateGitHubConnectionSchema,
|
||||
SanitizedGitHubConnectionSchema,
|
||||
UpdateGitHubConnectionSchema
|
||||
} from "@app/services/app-connection/github";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
||||
|
||||
export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => {
|
||||
registerAppConnectionEndpoints({
|
||||
app: AppConnection.GitHub,
|
||||
server,
|
||||
sanitizedResponseSchema: SanitizedGitHubConnectionSchema,
|
||||
createSchema: CreateGitHubConnectionSchema,
|
||||
updateSchema: UpdateGitHubConnectionSchema
|
||||
});
|
||||
|
||||
// The below endpoints are not exposed and for Infisical App use
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/:connectionId/repositories`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
connectionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
repositories: z
|
||||
.object({ id: z.number(), name: z.string(), owner: z.object({ login: z.string(), id: z.number() }) })
|
||||
.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { connectionId } = req.params;
|
||||
|
||||
const repositories = await server.services.appConnection.github.listRepositories(connectionId, req.permission);
|
||||
|
||||
return { repositories };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/:connectionId/organizations`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
connectionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
organizations: z.object({ id: z.number(), login: z.string() }).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { connectionId } = req.params;
|
||||
|
||||
const organizations = await server.services.appConnection.github.listOrganizations(connectionId, req.permission);
|
||||
|
||||
return { organizations };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/:connectionId/environments`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
connectionId: z.string().uuid()
|
||||
}),
|
||||
querystring: z.object({
|
||||
repo: z.string().min(1, "Repository name is required"),
|
||||
owner: z.string().min(1, "Repository owner name is required")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
environments: z.object({ id: z.number(), name: z.string() }).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { connectionId } = req.params;
|
||||
const { repo, owner } = req.query;
|
||||
|
||||
const environments = await server.services.appConnection.github.listEnvironments(
|
||||
{
|
||||
connectionId,
|
||||
repo,
|
||||
owner
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
return { environments };
|
||||
}
|
||||
});
|
||||
};
|
@ -1,2 +1,12 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { registerAwsConnectionRouter } from "./aws-connection-router";
|
||||
import { registerGitHubConnectionRouter } from "./github-connection-router";
|
||||
|
||||
export * from "./app-connection-router";
|
||||
export * from "./apps";
|
||||
|
||||
export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> =
|
||||
{
|
||||
[AppConnection.AWS]: registerAwsConnectionRouter,
|
||||
[AppConnection.GitHub]: registerGitHubConnectionRouter
|
||||
};
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "@app/server/routes/v1/app-connection-routers";
|
||||
import {
|
||||
APP_CONNECTION_REGISTER_ROUTER_MAP,
|
||||
registerAppConnectionRouter
|
||||
} from "@app/server/routes/v1/app-connection-routers";
|
||||
import { registerCmekRouter } from "@app/server/routes/v1/cmek-router";
|
||||
import { 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" }
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,17 @@
|
||||
import {
|
||||
AwsParameterStoreSyncSchema,
|
||||
CreateAwsParameterStoreSyncSchema,
|
||||
UpdateAwsParameterStoreSyncSchema
|
||||
} from "@app/services/secret-sync/aws-parameter-store";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||
|
||||
export const registerAwsParameterStoreSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncSecretsEndpoints({
|
||||
destination: SecretSync.AWSParameterStore,
|
||||
server,
|
||||
responseSchema: AwsParameterStoreSyncSchema,
|
||||
createSchema: CreateAwsParameterStoreSyncSchema,
|
||||
updateSchema: UpdateAwsParameterStoreSyncSchema
|
||||
});
|
@ -0,0 +1,13 @@
|
||||
import { CreateGitHubSyncSchema, GitHubSyncSchema, UpdateGitHubSyncSchema } from "@app/services/secret-sync/github";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||
|
||||
export const registerGitHubSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncSecretsEndpoints({
|
||||
destination: SecretSync.GitHub,
|
||||
server,
|
||||
responseSchema: GitHubSyncSchema,
|
||||
createSchema: CreateGitHubSyncSchema,
|
||||
updateSchema: UpdateGitHubSyncSchema
|
||||
});
|
11
backend/src/server/routes/v1/secret-sync-routers/index.ts
Normal file
11
backend/src/server/routes/v1/secret-sync-routers/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router";
|
||||
import { registerGitHubSyncRouter } from "./github-sync-router";
|
||||
|
||||
export * from "./secret-sync-router";
|
||||
|
||||
export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[SecretSync.AWSParameterStore]: registerAwsParameterStoreSyncRouter,
|
||||
[SecretSync.GitHub]: registerGitHubSyncRouter
|
||||
};
|
@ -0,0 +1,408 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { SecretSyncs } from "@app/lib/api-docs";
|
||||
import { startsWithVowel } from "@app/lib/fn";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||
import { TSecretSync, TSecretSyncInput } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TSecretSyncInput>({
|
||||
server,
|
||||
destination,
|
||||
createSchema,
|
||||
updateSchema,
|
||||
responseSchema
|
||||
}: {
|
||||
destination: SecretSync;
|
||||
server: FastifyZodProvider;
|
||||
createSchema: z.ZodType<{
|
||||
name: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
projectId: string;
|
||||
connectionId: string;
|
||||
destinationConfig: I["destinationConfig"];
|
||||
syncOptions: I["syncOptions"];
|
||||
description?: string | null;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
}>;
|
||||
updateSchema: z.ZodType<{
|
||||
connectionId?: string;
|
||||
name?: string;
|
||||
environment?: string;
|
||||
secretPath?: string;
|
||||
destinationConfig?: I["destinationConfig"];
|
||||
syncOptions?: I["syncOptions"];
|
||||
description?: string | null;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
}>;
|
||||
responseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
const destinationName = SECRET_SYNC_NAME_MAP[destination];
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: `List the ${destinationName} Syncs for the specified project.`,
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST(destination).projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSyncs: responseSchema.array() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const {
|
||||
query: { projectId }
|
||||
} = req;
|
||||
|
||||
const secretSyncs = (await server.services.secretSync.listSecretSyncsByProjectId(
|
||||
{ projectId, destination },
|
||||
req.permission
|
||||
)) as T[];
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.GET_SECRET_SYNCS,
|
||||
metadata: {
|
||||
destination,
|
||||
count: secretSyncs.length,
|
||||
syncIds: secretSyncs.map((connection) => connection.id)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSyncs };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:syncId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Get the specified ${destinationName} Sync by ID.`,
|
||||
params: z.object({
|
||||
syncId: z.string().uuid().describe(SecretSyncs.GET_BY_ID(destination).syncId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncId } = req.params;
|
||||
|
||||
const secretSync = (await server.services.secretSync.findSecretSyncById(
|
||||
{ syncId, destination },
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: secretSync.projectId,
|
||||
event: {
|
||||
type: EventType.GET_SECRET_SYNC,
|
||||
metadata: {
|
||||
syncId,
|
||||
destination
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/sync-name/:syncName`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Get the specified ${destinationName} Sync by name and project ID.`,
|
||||
params: z.object({
|
||||
syncName: z.string().trim().min(1, "Sync name required").describe(SecretSyncs.GET_BY_NAME(destination).syncName)
|
||||
}),
|
||||
querystring: z.object({
|
||||
projectId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Project ID required")
|
||||
.describe(SecretSyncs.GET_BY_NAME(destination).projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncName } = req.params;
|
||||
const { projectId } = req.query;
|
||||
|
||||
const secretSync = (await server.services.secretSync.findSecretSyncByName(
|
||||
{ syncName, projectId, destination },
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.GET_SECRET_SYNC,
|
||||
metadata: {
|
||||
syncId: secretSync.id,
|
||||
destination
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Create ${
|
||||
startsWithVowel(destinationName) ? "an" : "a"
|
||||
} ${destinationName} Sync for the specified project environment.`,
|
||||
body: createSchema,
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const secretSync = (await server.services.secretSync.createSecretSync(
|
||||
{ ...req.body, destination },
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: secretSync.projectId,
|
||||
event: {
|
||||
type: EventType.CREATE_SECRET_SYNC,
|
||||
metadata: {
|
||||
syncId: secretSync.id,
|
||||
destination,
|
||||
...req.body
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:syncId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Update the specified ${destinationName} Sync.`,
|
||||
params: z.object({
|
||||
syncId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).syncId)
|
||||
}),
|
||||
body: updateSchema,
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncId } = req.params;
|
||||
|
||||
const secretSync = (await server.services.secretSync.updateSecretSync(
|
||||
{ ...req.body, syncId, destination },
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: secretSync.projectId,
|
||||
event: {
|
||||
type: EventType.UPDATE_SECRET_SYNC,
|
||||
metadata: {
|
||||
syncId,
|
||||
destination,
|
||||
...req.body
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: `/:syncId`,
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Delete the specified ${destinationName} Sync.`,
|
||||
params: z.object({
|
||||
syncId: z.string().uuid().describe(SecretSyncs.DELETE(destination).syncId)
|
||||
}),
|
||||
querystring: z.object({
|
||||
removeSecrets: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((value) => value === "true")
|
||||
.describe(SecretSyncs.DELETE(destination).removeSecrets)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncId } = req.params;
|
||||
const { removeSecrets } = req.query;
|
||||
|
||||
const secretSync = (await server.services.secretSync.deleteSecretSync(
|
||||
{ destination, syncId, removeSecrets },
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
event: {
|
||||
type: EventType.DELETE_SECRET_SYNC,
|
||||
metadata: {
|
||||
destination,
|
||||
syncId,
|
||||
removeSecrets
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:syncId/sync-secrets",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Trigger a sync for the specified ${destinationName} Sync.`,
|
||||
params: z.object({
|
||||
syncId: z.string().uuid().describe(SecretSyncs.SYNC_SECRETS(destination).syncId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncId } = req.params;
|
||||
|
||||
const secretSync = (await server.services.secretSync.triggerSecretSyncSyncSecretsById(
|
||||
{
|
||||
syncId,
|
||||
destination,
|
||||
auditLogInfo: req.auditLogInfo
|
||||
},
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:syncId/import-secrets",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Import secrets from the specified ${destinationName} Sync destination.`,
|
||||
params: z.object({
|
||||
syncId: z.string().uuid().describe(SecretSyncs.IMPORT_SECRETS(destination).syncId)
|
||||
}),
|
||||
querystring: z.object({
|
||||
importBehavior: z
|
||||
.nativeEnum(SecretSyncImportBehavior)
|
||||
.describe(SecretSyncs.IMPORT_SECRETS(destination).importBehavior)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncId } = req.params;
|
||||
const { importBehavior } = req.query;
|
||||
|
||||
const secretSync = (await server.services.secretSync.triggerSecretSyncImportSecretsById(
|
||||
{
|
||||
syncId,
|
||||
destination,
|
||||
importBehavior
|
||||
},
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:syncId/remove-secrets",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: `Remove previously synced secrets from the specified ${destinationName} Sync destination.`,
|
||||
params: z.object({
|
||||
syncId: z.string().uuid().describe(SecretSyncs.REMOVE_SECRETS(destination).syncId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSync: responseSchema })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { syncId } = req.params;
|
||||
|
||||
const secretSync = (await server.services.secretSync.triggerSecretSyncRemoveSecretsById(
|
||||
{
|
||||
syncId,
|
||||
destination
|
||||
},
|
||||
req.permission
|
||||
)) as T;
|
||||
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { SecretSyncs } from "@app/lib/api-docs";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import {
|
||||
AwsParameterStoreSyncListItemSchema,
|
||||
AwsParameterStoreSyncSchema
|
||||
} from "@app/services/secret-sync/aws-parameter-store";
|
||||
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
|
||||
|
||||
const SecretSyncSchema = z.discriminatedUnion("destination", [AwsParameterStoreSyncSchema, GitHubSyncSchema]);
|
||||
|
||||
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
|
||||
AwsParameterStoreSyncListItemSchema,
|
||||
GitHubSyncListItemSchema
|
||||
]);
|
||||
|
||||
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/options",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List the available Secret Sync Options.",
|
||||
response: {
|
||||
200: z.object({
|
||||
secretSyncOptions: SecretSyncOptionsSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: () => {
|
||||
const secretSyncOptions = server.services.secretSync.listSecretSyncOptions();
|
||||
return { secretSyncOptions };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List all the Secret Syncs for the specified project.",
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST().projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ secretSyncs: SecretSyncSchema.array() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const {
|
||||
query: { projectId },
|
||||
permission
|
||||
} = req;
|
||||
|
||||
const secretSyncs = await server.services.secretSync.listSecretSyncsByProjectId({ projectId }, permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.GET_SECRET_SYNCS,
|
||||
metadata: {
|
||||
syncIds: secretSyncs.map((sync) => sync.id),
|
||||
count: secretSyncs.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { secretSyncs };
|
||||
}
|
||||
});
|
||||
};
|
@ -2,3 +2,50 @@ export enum AppConnection {
|
||||
GitHub = "github",
|
||||
AWS = "aws"
|
||||
}
|
||||
|
||||
export enum AWSRegion {
|
||||
// US
|
||||
US_EAST_1 = "us-east-1", // N. Virginia
|
||||
US_EAST_2 = "us-east-2", // Ohio
|
||||
US_WEST_1 = "us-west-1", // N. California
|
||||
US_WEST_2 = "us-west-2", // Oregon
|
||||
|
||||
// GovCloud
|
||||
US_GOV_EAST_1 = "us-gov-east-1", // US-East
|
||||
US_GOV_WEST_1 = "us-gov-west-1", // US-West
|
||||
|
||||
// Africa
|
||||
AF_SOUTH_1 = "af-south-1", // Cape Town
|
||||
|
||||
// Asia Pacific
|
||||
AP_EAST_1 = "ap-east-1", // Hong Kong
|
||||
AP_SOUTH_1 = "ap-south-1", // Mumbai
|
||||
AP_SOUTH_2 = "ap-south-2", // Hyderabad
|
||||
AP_NORTHEAST_1 = "ap-northeast-1", // Tokyo
|
||||
AP_NORTHEAST_2 = "ap-northeast-2", // Seoul
|
||||
AP_NORTHEAST_3 = "ap-northeast-3", // Osaka
|
||||
AP_SOUTHEAST_1 = "ap-southeast-1", // Singapore
|
||||
AP_SOUTHEAST_2 = "ap-southeast-2", // Sydney
|
||||
AP_SOUTHEAST_3 = "ap-southeast-3", // Jakarta
|
||||
AP_SOUTHEAST_4 = "ap-southeast-4", // Melbourne
|
||||
|
||||
// Canada
|
||||
CA_CENTRAL_1 = "ca-central-1", // Central
|
||||
|
||||
// Europe
|
||||
EU_CENTRAL_1 = "eu-central-1", // Frankfurt
|
||||
EU_CENTRAL_2 = "eu-central-2", // Zurich
|
||||
EU_WEST_1 = "eu-west-1", // Ireland
|
||||
EU_WEST_2 = "eu-west-2", // London
|
||||
EU_WEST_3 = "eu-west-3", // Paris
|
||||
EU_SOUTH_1 = "eu-south-1", // Milan
|
||||
EU_SOUTH_2 = "eu-south-2", // Spain
|
||||
EU_NORTH_1 = "eu-north-1", // Stockholm
|
||||
|
||||
// Middle East
|
||||
ME_SOUTH_1 = "me-south-1", // Bahrain
|
||||
ME_CENTRAL_1 = "me-central-1", // UAE
|
||||
|
||||
// South America
|
||||
SA_EAST_1 = "sa-east-1" // Sao Paulo
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { TAppConnections } from "@app/db/schemas/app-connections";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { 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;
|
||||
};
|
||||
|
@ -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)
|
||||
};
|
||||
};
|
||||
|
@ -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 ??
|
||||
|
@ -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()
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
@ -0,0 +1,55 @@
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
getGitHubEnvironments,
|
||||
getGitHubOrganizations,
|
||||
getGitHubRepositories
|
||||
} from "@app/services/app-connection/github/github-connection-fns";
|
||||
import { TGitHubConnection } from "@app/services/app-connection/github/github-connection-types";
|
||||
|
||||
type TGetAppConnectionFunc = (
|
||||
app: AppConnection,
|
||||
connectionId: string,
|
||||
actor: OrgServiceActor
|
||||
) => Promise<TGitHubConnection>;
|
||||
|
||||
type TListGitHubEnvironmentsDTO = {
|
||||
connectionId: string;
|
||||
repo: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
|
||||
const listRepositories = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
|
||||
|
||||
const repositories = await getGitHubRepositories(appConnection);
|
||||
|
||||
return repositories;
|
||||
};
|
||||
|
||||
const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
|
||||
|
||||
const organizations = await getGitHubOrganizations(appConnection);
|
||||
|
||||
return organizations;
|
||||
};
|
||||
|
||||
const listEnvironments = async (
|
||||
{ connectionId, repo, owner }: TListGitHubEnvironmentsDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor);
|
||||
|
||||
const environments = await getGitHubEnvironments(appConnection, owner, repo);
|
||||
|
||||
return environments;
|
||||
};
|
||||
|
||||
return {
|
||||
listRepositories,
|
||||
listOrganizations,
|
||||
listEnvironments
|
||||
};
|
||||
};
|
@ -5,11 +5,11 @@ import { DiscriminativePick } from "@app/lib/types";
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
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;
|
||||
|
10
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-constants.ts
Normal file
10
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-constants.ts
Normal 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
|
||||
};
|
207
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts
Normal file
207
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts
Normal 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);
|
||||
}
|
||||
};
|
45
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts
Normal file
45
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts
Normal 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)
|
||||
});
|
19
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-types.ts
Normal file
19
backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-types.ts
Normal 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;
|
||||
};
|
@ -0,0 +1,4 @@
|
||||
export * from "./aws-parameter-store-sync-constants";
|
||||
export * from "./aws-parameter-store-sync-fns";
|
||||
export * from "./aws-parameter-store-sync-schemas";
|
||||
export * from "./aws-parameter-store-sync-types";
|
@ -0,0 +1,10 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
export const GITHUB_SYNC_LIST_OPTION: TSecretSyncListItem = {
|
||||
name: "GitHub",
|
||||
destination: SecretSync.GitHub,
|
||||
connection: AppConnection.GitHub,
|
||||
canImportSecrets: false
|
||||
};
|
11
backend/src/services/secret-sync/github/github-sync-enums.ts
Normal file
11
backend/src/services/secret-sync/github/github-sync-enums.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export enum GitHubSyncScope {
|
||||
Repository = "repository",
|
||||
Organization = "organization",
|
||||
RepositoryEnvironment = "repository-environment"
|
||||
}
|
||||
|
||||
export enum GitHubSyncVisibility {
|
||||
All = "all",
|
||||
Private = "private",
|
||||
Selected = "selected"
|
||||
}
|
242
backend/src/services/secret-sync/github/github-sync-fns.ts
Normal file
242
backend/src/services/secret-sync/github/github-sync-fns.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
import { getGitHubClient } from "@app/services/app-connection/github";
|
||||
import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums";
|
||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithCredentials } from "./github-sync-types";
|
||||
|
||||
// TODO: rate limit handling
|
||||
|
||||
const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => {
|
||||
let encryptedSecrets: TGitHubSecret[];
|
||||
|
||||
const { destinationConfig } = secretSync;
|
||||
|
||||
switch (destinationConfig.scope) {
|
||||
case GitHubSyncScope.Organization: {
|
||||
encryptedSecrets = await client.paginate("GET /orgs/{org}/actions/secrets", {
|
||||
org: destinationConfig.org
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.Repository: {
|
||||
encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/actions/secrets", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.RepositoryEnvironment:
|
||||
default: {
|
||||
encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo,
|
||||
environment_name: destinationConfig.env
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedSecrets;
|
||||
};
|
||||
|
||||
const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => {
|
||||
let publicKey: TGitHubPublicKey;
|
||||
|
||||
const { destinationConfig } = secretSync;
|
||||
|
||||
switch (destinationConfig.scope) {
|
||||
case GitHubSyncScope.Organization: {
|
||||
publicKey = (
|
||||
await client.request("GET /orgs/{org}/actions/secrets/public-key", {
|
||||
org: destinationConfig.org
|
||||
})
|
||||
).data;
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.Repository: {
|
||||
publicKey = (
|
||||
await client.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo
|
||||
})
|
||||
).data;
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.RepositoryEnvironment:
|
||||
default: {
|
||||
publicKey = (
|
||||
await client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo,
|
||||
environment_name: destinationConfig.env
|
||||
})
|
||||
).data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
const deleteSecret = async (
|
||||
client: Octokit,
|
||||
secretSync: TGitHubSyncWithCredentials,
|
||||
encryptedSecret: TGitHubSecret
|
||||
) => {
|
||||
const { destinationConfig } = secretSync;
|
||||
|
||||
switch (destinationConfig.scope) {
|
||||
case GitHubSyncScope.Organization: {
|
||||
await client.request(`DELETE /orgs/{org}/actions/secrets/{secret_name}`, {
|
||||
org: destinationConfig.org,
|
||||
secret_name: encryptedSecret.name
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.Repository: {
|
||||
await client.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo,
|
||||
secret_name: encryptedSecret.name
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.RepositoryEnvironment:
|
||||
default: {
|
||||
await client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo,
|
||||
environment_name: destinationConfig.env,
|
||||
secret_name: encryptedSecret.name
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: TGitHubSecretPayload) => {
|
||||
const { destinationConfig } = secretSync;
|
||||
|
||||
switch (destinationConfig.scope) {
|
||||
case GitHubSyncScope.Organization: {
|
||||
const { visibility, selectedRepositoryIds } = destinationConfig;
|
||||
|
||||
await client.request(`PUT /orgs/{org}/actions/secrets/{secret_name}`, {
|
||||
org: destinationConfig.org,
|
||||
...payload,
|
||||
visibility,
|
||||
...(visibility === GitHubSyncVisibility.Selected && {
|
||||
selected_repository_ids: selectedRepositoryIds
|
||||
})
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.Repository: {
|
||||
await client.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo,
|
||||
...payload
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GitHubSyncScope.RepositoryEnvironment:
|
||||
default: {
|
||||
await client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
|
||||
owner: destinationConfig.owner,
|
||||
repo: destinationConfig.repo,
|
||||
environment_name: destinationConfig.env,
|
||||
...payload
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const GithubSyncFns = {
|
||||
syncSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
switch (secretSync.destinationConfig.scope) {
|
||||
case GitHubSyncScope.Organization:
|
||||
if (Object.values(secretMap).length > 1000) {
|
||||
throw new SecretSyncError({
|
||||
message: "GitHub does not support storing more than 1,000 secrets at the organization level.",
|
||||
shouldRetry: false
|
||||
});
|
||||
}
|
||||
break;
|
||||
case GitHubSyncScope.Repository:
|
||||
case GitHubSyncScope.RepositoryEnvironment:
|
||||
if (Object.values(secretMap).length > 100) {
|
||||
throw new SecretSyncError({
|
||||
message: "GitHub does not support storing more than 100 secrets at the repository level.",
|
||||
shouldRetry: false
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported GitHub Sync scope ${
|
||||
(secretSync.destinationConfig as TGitHubSyncWithCredentials["destinationConfig"]).scope
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const client = getGitHubClient(secretSync.connection);
|
||||
|
||||
const encryptedSecrets = await getEncryptedSecrets(client, secretSync);
|
||||
|
||||
const publicKey = await getPublicKey(client, secretSync);
|
||||
|
||||
for await (const encryptedSecret of encryptedSecrets) {
|
||||
if (!(encryptedSecret.name in secretMap)) {
|
||||
await deleteSecret(client, secretSync, encryptedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
await sodium.ready.then(async () => {
|
||||
for await (const key of Object.keys(secretMap)) {
|
||||
// convert secret & base64 key to Uint8Array.
|
||||
const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL);
|
||||
const binarySecretValue = sodium.from_string(secretMap[key].value);
|
||||
|
||||
// encrypt secret using libsodium
|
||||
const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey);
|
||||
|
||||
// convert encrypted Uint8Array to base64
|
||||
const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
||||
|
||||
try {
|
||||
await putSecret(client, secretSync, {
|
||||
secret_name: key,
|
||||
encrypted_value: encryptedSecretValue,
|
||||
key_id: publicKey.key_id
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: key
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
getSecrets: async (secretSync: TGitHubSyncWithCredentials) => {
|
||||
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`);
|
||||
},
|
||||
removeSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const client = getGitHubClient(secretSync.connection);
|
||||
|
||||
const encryptedSecrets = await getEncryptedSecrets(client, secretSync);
|
||||
|
||||
for await (const encryptedSecret of encryptedSecrets) {
|
||||
if (encryptedSecret.name in secretMap) {
|
||||
await deleteSecret(client, secretSync, encryptedSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSyncs } from "@app/lib/api-docs";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import {
|
||||
BaseSecretSyncSchema,
|
||||
GenericCreateSecretSyncFieldsSchema,
|
||||
GenericUpdateSecretSyncFieldsSchema
|
||||
} from "@app/services/secret-sync/secret-sync-schemas";
|
||||
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const GitHubSyncDestinationConfigSchema = z
|
||||
.discriminatedUnion("scope", [
|
||||
z.object({
|
||||
scope: z.literal(GitHubSyncScope.Organization),
|
||||
org: z.string().min(1, "Organization name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ORG),
|
||||
visibility: z.nativeEnum(GitHubSyncVisibility),
|
||||
selectedRepositoryIds: z.number().array().optional()
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal(GitHubSyncScope.Repository),
|
||||
owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER),
|
||||
repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO)
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
|
||||
owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER),
|
||||
repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO),
|
||||
env: z.string().min(1, "Environment name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ENV)
|
||||
})
|
||||
])
|
||||
.superRefine((options, ctx) => {
|
||||
if (options.scope === GitHubSyncScope.Organization) {
|
||||
if (options.visibility === GitHubSyncVisibility.Selected) {
|
||||
if (!options.selectedRepositoryIds?.length)
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Select at least 1 repository",
|
||||
path: ["selectedRepositoryIds"]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.selectedRepositoryIds?.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Selected repositories is only supported for visibility "Selected"`,
|
||||
path: ["selectedRepositoryIds"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const GitHubSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
|
||||
|
||||
export const GitHubSyncSchema = BaseSecretSyncSchema(SecretSync.GitHub, GitHubSyncOptionsConfig).extend({
|
||||
destination: z.literal(SecretSync.GitHub),
|
||||
destinationConfig: GitHubSyncDestinationConfigSchema
|
||||
});
|
||||
|
||||
export const CreateGitHubSyncSchema = GenericCreateSecretSyncFieldsSchema(
|
||||
SecretSync.GitHub,
|
||||
GitHubSyncOptionsConfig
|
||||
).extend({
|
||||
destinationConfig: GitHubSyncDestinationConfigSchema
|
||||
});
|
||||
|
||||
export const UpdateGitHubSyncSchema = GenericUpdateSecretSyncFieldsSchema(
|
||||
SecretSync.GitHub,
|
||||
GitHubSyncOptionsConfig
|
||||
).extend({
|
||||
destinationConfig: GitHubSyncDestinationConfigSchema.optional()
|
||||
});
|
||||
|
||||
export const GitHubSyncListItemSchema = z.object({
|
||||
name: z.literal("GitHub"),
|
||||
connection: z.literal(AppConnection.GitHub),
|
||||
destination: z.literal(SecretSync.GitHub),
|
||||
canImportSecrets: z.literal(false)
|
||||
});
|
38
backend/src/services/secret-sync/github/github-sync-types.ts
Normal file
38
backend/src/services/secret-sync/github/github-sync-types.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TGitHubConnection } from "@app/services/app-connection/github";
|
||||
|
||||
import { CreateGitHubSyncSchema, GitHubSyncListItemSchema, GitHubSyncSchema } from "./github-sync-schemas";
|
||||
|
||||
export type TGitHubSync = z.infer<typeof GitHubSyncSchema>;
|
||||
|
||||
export type TGitHubSyncInput = z.infer<typeof CreateGitHubSyncSchema>;
|
||||
|
||||
export type TGitHubSyncListItem = z.infer<typeof GitHubSyncListItemSchema>;
|
||||
|
||||
export type TGitHubSyncWithCredentials = TGitHubSync & {
|
||||
connection: TGitHubConnection;
|
||||
};
|
||||
|
||||
export type TGitHubSecret = {
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
visibility?: "all" | "private" | "selected";
|
||||
selected_repositories_url?: string | undefined;
|
||||
};
|
||||
|
||||
export type TGitHubPublicKey = {
|
||||
key_id: string;
|
||||
key: string;
|
||||
id?: number | undefined;
|
||||
url?: string | undefined;
|
||||
title?: string | undefined;
|
||||
created_at?: string | undefined;
|
||||
};
|
||||
|
||||
export type TGitHubSecretPayload = {
|
||||
key_id: string;
|
||||
secret_name: string;
|
||||
encrypted_value: string;
|
||||
};
|
4
backend/src/services/secret-sync/github/index.ts
Normal file
4
backend/src/services/secret-sync/github/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./github-sync-constants";
|
||||
export * from "./github-sync-fns";
|
||||
export * from "./github-sync-schemas";
|
||||
export * from "./github-sync-types";
|
212
backend/src/services/secret-sync/secret-sync-dal.ts
Normal file
212
backend/src/services/secret-sync/secret-sync-dal.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { TSecretSyncs } from "@app/db/schemas/secret-syncs";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
|
||||
export type TSecretSyncDALFactory = ReturnType<typeof secretSyncDALFactory>;
|
||||
|
||||
type SecretSyncFindFilter = Parameters<typeof buildFindFilter<TSecretSyncs>>[0];
|
||||
|
||||
const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: SecretSyncFindFilter; tx?: Knex }) => {
|
||||
const query = (tx || db.replicaNode())(TableName.SecretSync)
|
||||
.leftJoin(TableName.SecretFolder, `${TableName.SecretSync}.folderId`, `${TableName.SecretFolder}.id`)
|
||||
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||
.join(TableName.AppConnection, `${TableName.SecretSync}.connectionId`, `${TableName.AppConnection}.id`)
|
||||
.select(selectAllTableCols(TableName.SecretSync))
|
||||
.select(
|
||||
// environment
|
||||
db.ref("name").withSchema(TableName.Environment).as("envName"),
|
||||
db.ref("id").withSchema(TableName.Environment).as("envId"),
|
||||
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
|
||||
// entire connection
|
||||
db.ref("name").withSchema(TableName.AppConnection).as("connectionName"),
|
||||
db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"),
|
||||
db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"),
|
||||
db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"),
|
||||
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"),
|
||||
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
|
||||
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
|
||||
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
|
||||
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt")
|
||||
);
|
||||
|
||||
// prepends table name to filter keys to avoid ambiguous col references, skipping utility filters like $in, etc.
|
||||
const prependTableName = (filterObj: object): SecretSyncFindFilter =>
|
||||
Object.fromEntries(
|
||||
Object.entries(filterObj).map(([key, value]) =>
|
||||
key.startsWith("$") ? [key, prependTableName(value as object)] : [`${TableName.SecretSync}.${key}`, value]
|
||||
)
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
void query.where(buildFindFilter(prependTableName(filter)));
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const expandSecretSync = (
|
||||
secretSync: Awaited<ReturnType<typeof baseSecretSyncQuery>>[number],
|
||||
folder?: Awaited<ReturnType<TSecretFolderDALFactory["findSecretPathByFolderIds"]>>[number]
|
||||
) => {
|
||||
const {
|
||||
envId,
|
||||
envName,
|
||||
envSlug,
|
||||
connectionApp,
|
||||
connectionName,
|
||||
connectionId,
|
||||
connectionOrgId,
|
||||
connectionEncryptedCredentials,
|
||||
connectionMethod,
|
||||
connectionDescription,
|
||||
connectionCreatedAt,
|
||||
connectionUpdatedAt,
|
||||
connectionVersion,
|
||||
...el
|
||||
} = secretSync;
|
||||
|
||||
return {
|
||||
...el,
|
||||
connectionId,
|
||||
environment: envId ? { id: envId, name: envName, slug: envSlug } : null,
|
||||
connection: {
|
||||
app: connectionApp,
|
||||
id: connectionId,
|
||||
name: connectionName,
|
||||
orgId: connectionOrgId,
|
||||
encryptedCredentials: connectionEncryptedCredentials,
|
||||
method: connectionMethod,
|
||||
description: connectionDescription,
|
||||
createdAt: connectionCreatedAt,
|
||||
updatedAt: connectionUpdatedAt,
|
||||
version: connectionVersion
|
||||
},
|
||||
folder: folder
|
||||
? {
|
||||
id: folder.id,
|
||||
path: folder.path
|
||||
}
|
||||
: null
|
||||
};
|
||||
};
|
||||
|
||||
export const secretSyncDALFactory = (
|
||||
db: TDbClient,
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">
|
||||
) => {
|
||||
const secretSyncOrm = ormify(db, TableName.SecretSync);
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const secretSync = await baseSecretSyncQuery({
|
||||
filter: { id },
|
||||
db,
|
||||
tx
|
||||
}).first();
|
||||
|
||||
if (secretSync) {
|
||||
// TODO (scott): replace with cached folder path once implemented
|
||||
const [folderWithPath] = secretSync.folderId
|
||||
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||
: [];
|
||||
return expandSecretSync(secretSync, folderWithPath);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find by ID - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const create = async (data: Parameters<(typeof secretSyncOrm)["create"]>[0]) => {
|
||||
try {
|
||||
const secretSync = (await secretSyncOrm.transaction(async (tx) => {
|
||||
const sync = await secretSyncOrm.create(data, tx);
|
||||
|
||||
return baseSecretSyncQuery({
|
||||
filter: { id: sync.id },
|
||||
db,
|
||||
tx
|
||||
}).first();
|
||||
}))!;
|
||||
|
||||
// TODO (scott): replace with cached folder path once implemented
|
||||
const [folderWithPath] = secretSync.folderId
|
||||
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||
: [];
|
||||
return expandSecretSync(secretSync, folderWithPath);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (syncId: string, data: Parameters<(typeof secretSyncOrm)["updateById"]>[1]) => {
|
||||
try {
|
||||
const secretSync = (await secretSyncOrm.transaction(async (tx) => {
|
||||
const sync = await secretSyncOrm.updateById(syncId, data, tx);
|
||||
|
||||
return baseSecretSyncQuery({
|
||||
filter: { id: sync.id },
|
||||
db,
|
||||
tx
|
||||
}).first();
|
||||
}))!;
|
||||
|
||||
// TODO (scott): replace with cached folder path once implemented
|
||||
const [folderWithPath] = secretSync.folderId
|
||||
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||
: [];
|
||||
return expandSecretSync(secretSync, folderWithPath);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update by ID - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findOne = async (filter: Parameters<(typeof secretSyncOrm)["findOne"]>[0], tx?: Knex) => {
|
||||
try {
|
||||
const secretSync = await baseSecretSyncQuery({ filter, db, tx }).first();
|
||||
|
||||
if (secretSync) {
|
||||
// TODO (scott): replace with cached folder path once implemented
|
||||
const [folderWithPath] = secretSync.folderId
|
||||
? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId])
|
||||
: [];
|
||||
return expandSecretSync(secretSync, folderWithPath);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find One - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const find = async (filter: Parameters<(typeof secretSyncOrm)["find"]>[0], tx?: Knex) => {
|
||||
try {
|
||||
const secretSyncs = await baseSecretSyncQuery({ filter, db, tx });
|
||||
|
||||
if (!secretSyncs.length) return [];
|
||||
|
||||
const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
|
||||
secretSyncs[0].projectId,
|
||||
secretSyncs.filter((sync) => Boolean(sync.folderId)).map((sync) => sync.folderId!)
|
||||
);
|
||||
|
||||
// TODO (scott): replace with cached folder path once implemented
|
||||
const folderRecord: Record<string, (typeof foldersWithPath)[number]> = {};
|
||||
|
||||
foldersWithPath.forEach((folder) => {
|
||||
if (folder) folderRecord[folder.id] = folder;
|
||||
});
|
||||
|
||||
return secretSyncs.map((secretSync) =>
|
||||
expandSecretSync(secretSync, secretSync.folderId ? folderRecord[secretSync.folderId] : undefined)
|
||||
);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretSyncOrm, findById, findOne, find, create, updateById };
|
||||
};
|
15
backend/src/services/secret-sync/secret-sync-enums.ts
Normal file
15
backend/src/services/secret-sync/secret-sync-enums.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export enum SecretSync {
|
||||
AWSParameterStore = "aws-parameter-store",
|
||||
GitHub = "github"
|
||||
}
|
||||
|
||||
export enum SecretSyncInitialSyncBehavior {
|
||||
OverwriteDestination = "overwrite-destination",
|
||||
ImportPrioritizeSource = "import-prioritize-source",
|
||||
ImportPrioritizeDestination = "import-prioritize-destination"
|
||||
}
|
||||
|
||||
export enum SecretSyncImportBehavior {
|
||||
PrioritizeSource = "prioritize-source",
|
||||
PrioritizeDestination = "prioritize-destination"
|
||||
}
|
23
backend/src/services/secret-sync/secret-sync-errors.ts
Normal file
23
backend/src/services/secret-sync/secret-sync-errors.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export class SecretSyncError extends Error {
|
||||
name: string;
|
||||
|
||||
error?: unknown;
|
||||
|
||||
secretKey?: string;
|
||||
|
||||
shouldRetry?: boolean;
|
||||
|
||||
constructor({
|
||||
name,
|
||||
error,
|
||||
secretKey,
|
||||
message,
|
||||
shouldRetry = true
|
||||
}: { name?: string; error?: unknown; secretKey?: string; shouldRetry?: boolean; message?: string } = {}) {
|
||||
super(message);
|
||||
this.name = name || "SecretSyncError";
|
||||
this.error = error;
|
||||
this.secretKey = secretKey;
|
||||
this.shouldRetry = shouldRetry;
|
||||
}
|
||||
}
|
127
backend/src/services/secret-sync/secret-sync-fns.ts
Normal file
127
backend/src/services/secret-sync/secret-sync-fns.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import {
|
||||
AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
|
||||
AwsParameterStoreSyncFns
|
||||
} from "@app/services/secret-sync/aws-parameter-store";
|
||||
import { GITHUB_SYNC_LIST_OPTION, GithubSyncFns } from "@app/services/secret-sync/github";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
import {
|
||||
TSecretMap,
|
||||
TSecretSyncListItem,
|
||||
TSecretSyncWithCredentials
|
||||
} from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
|
||||
[SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION,
|
||||
[SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION
|
||||
};
|
||||
|
||||
export const listSecretSyncOptions = () => {
|
||||
return Object.values(SECRET_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
// const addAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => {
|
||||
// let secretMap = { ...unprocessedSecretMap };
|
||||
//
|
||||
// const { appendSuffix, prependPrefix } = secretSync.syncOptions;
|
||||
//
|
||||
// if (appendSuffix || prependPrefix) {
|
||||
// secretMap = {};
|
||||
// Object.entries(unprocessedSecretMap).forEach(([key, value]) => {
|
||||
// secretMap[`${prependPrefix || ""}${key}${appendSuffix || ""}`] = value;
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// return secretMap;
|
||||
// };
|
||||
//
|
||||
// const stripAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => {
|
||||
// let secretMap = { ...unprocessedSecretMap };
|
||||
//
|
||||
// const { appendSuffix, prependPrefix } = secretSync.syncOptions;
|
||||
//
|
||||
// if (appendSuffix || prependPrefix) {
|
||||
// secretMap = {};
|
||||
// Object.entries(unprocessedSecretMap).forEach(([key, value]) => {
|
||||
// let processedKey = key;
|
||||
//
|
||||
// if (prependPrefix && processedKey.startsWith(prependPrefix)) {
|
||||
// processedKey = processedKey.slice(prependPrefix.length);
|
||||
// }
|
||||
//
|
||||
// if (appendSuffix && processedKey.endsWith(appendSuffix)) {
|
||||
// processedKey = processedKey.slice(0, -appendSuffix.length);
|
||||
// }
|
||||
//
|
||||
// secretMap[processedKey] = value;
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// return secretMap;
|
||||
// };
|
||||
|
||||
export const SecretSyncFns = {
|
||||
syncSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => {
|
||||
// const affixedSecretMap = addAffixes(secretSync, secretMap);
|
||||
|
||||
switch (secretSync.destination) {
|
||||
case SecretSync.AWSParameterStore:
|
||||
return AwsParameterStoreSyncFns.syncSecrets(secretSync, secretMap);
|
||||
case SecretSync.GitHub:
|
||||
return GithubSyncFns.syncSecrets(secretSync, secretMap);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||
);
|
||||
}
|
||||
},
|
||||
getSecrets: async (secretSync: TSecretSyncWithCredentials): Promise<TSecretMap> => {
|
||||
let secretMap: TSecretMap;
|
||||
switch (secretSync.destination) {
|
||||
case SecretSync.AWSParameterStore:
|
||||
secretMap = await AwsParameterStoreSyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
case SecretSync.GitHub:
|
||||
secretMap = await GithubSyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||
);
|
||||
}
|
||||
|
||||
return secretMap;
|
||||
// return stripAffixes(secretSync, secretMap);
|
||||
},
|
||||
removeSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => {
|
||||
// const affixedSecretMap = addAffixes(secretSync, secretMap);
|
||||
|
||||
switch (secretSync.destination) {
|
||||
case SecretSync.AWSParameterStore:
|
||||
return AwsParameterStoreSyncFns.removeSecrets(secretSync, secretMap);
|
||||
case SecretSync.GitHub:
|
||||
return GithubSyncFns.removeSecrets(secretSync, secretMap);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const parseSyncErrorMessage = (err: unknown): string => {
|
||||
if (err instanceof SecretSyncError) {
|
||||
return JSON.stringify({
|
||||
secretKey: err.secretKey,
|
||||
error: err.message ?? parseSyncErrorMessage(err.error)
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
return err?.response?.data ? JSON.stringify(err?.response?.data) : err?.message ?? "An unknown error occurred.";
|
||||
}
|
||||
|
||||
return (err as Error)?.message || "An unknown error occurred.";
|
||||
};
|
12
backend/src/services/secret-sync/secret-sync-maps.ts
Normal file
12
backend/src/services/secret-sync/secret-sync-maps.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
|
||||
[SecretSync.GitHub]: "GitHub"
|
||||
};
|
||||
|
||||
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
|
||||
[SecretSync.AWSParameterStore]: AppConnection.AWS,
|
||||
[SecretSync.GitHub]: AppConnection.GitHub
|
||||
};
|
955
backend/src/services/secret-sync/secret-sync-queue.ts
Normal file
955
backend/src/services/secret-sync/secret-sync-queue.ts
Normal file
@ -0,0 +1,955 @@
|
||||
import opentelemetry from "@opentelemetry/api";
|
||||
import { AxiosError } from "axios";
|
||||
import { Job } from "bullmq";
|
||||
|
||||
import { ProjectMembershipRole, SecretType } from "@app/db/schemas";
|
||||
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||
import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
|
||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||
import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@app/services/secret/secret-fns";
|
||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
|
||||
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
|
||||
import { fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns";
|
||||
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
|
||||
import {
|
||||
SecretSync,
|
||||
SecretSyncImportBehavior,
|
||||
SecretSyncInitialSyncBehavior
|
||||
} from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
import { parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||
import {
|
||||
SecretSyncAction,
|
||||
SecretSyncStatus,
|
||||
TQueueSecretSyncImportSecretsByIdDTO,
|
||||
TQueueSecretSyncRemoveSecretsByIdDTO,
|
||||
TQueueSecretSyncsByPathDTO,
|
||||
TQueueSecretSyncSyncSecretsByIdDTO,
|
||||
TQueueSendSecretSyncActionFailedNotificationsDTO,
|
||||
TSecretMap,
|
||||
TSecretSyncImportSecretsDTO,
|
||||
TSecretSyncRaw,
|
||||
TSecretSyncRemoveSecretsDTO,
|
||||
TSecretSyncSyncSecretsDTO,
|
||||
TSecretSyncWithCredentials,
|
||||
TSendSecretSyncFailedNotificationsJobDTO
|
||||
} from "@app/services/secret-sync/secret-sync-types";
|
||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { expandSecretReferencesFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
|
||||
import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
|
||||
import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
|
||||
|
||||
type TSecretSyncQueueFactoryDep = {
|
||||
queueService: Pick<TQueueServiceFactory, "queue" | "start">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
|
||||
folderDAL: TSecretFolderDALFactory;
|
||||
secretV2BridgeDAL: Pick<
|
||||
TSecretV2BridgeDALFactory,
|
||||
| "findByFolderId"
|
||||
| "find"
|
||||
| "insertMany"
|
||||
| "upsertSecretReferences"
|
||||
| "findBySecretKeys"
|
||||
| "bulkUpdate"
|
||||
| "deleteMany"
|
||||
>;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||
secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
|
||||
projectDAL: TProjectDALFactory;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
projectBotDAL: TProjectBotDALFactory;
|
||||
secretDAL: TSecretDALFactory;
|
||||
secretVersionDAL: TSecretVersionDALFactory;
|
||||
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
|
||||
secretTagDAL: TSecretTagDALFactory;
|
||||
secretVersionTagDAL: TSecretVersionTagDALFactory;
|
||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
|
||||
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||
};
|
||||
|
||||
type SecretSyncActionJob = Job<
|
||||
TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO
|
||||
>;
|
||||
|
||||
const getRequeueDelay = (failureCount?: number) => {
|
||||
if (!failureCount) return 0;
|
||||
|
||||
const baseDelay = 1000;
|
||||
const maxDelay = 30000;
|
||||
|
||||
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
|
||||
|
||||
const jitter = delay * (0.5 + Math.random() * 0.5);
|
||||
|
||||
return jitter;
|
||||
};
|
||||
|
||||
export const secretSyncQueueFactory = ({
|
||||
queueService,
|
||||
kmsService,
|
||||
keyStore,
|
||||
folderDAL,
|
||||
secretV2BridgeDAL,
|
||||
secretImportDAL,
|
||||
secretSyncDAL,
|
||||
auditLogService,
|
||||
projectMembershipDAL,
|
||||
projectDAL,
|
||||
smtpService,
|
||||
projectBotDAL,
|
||||
secretDAL,
|
||||
secretVersionDAL,
|
||||
secretBlindIndexDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
secretVersionV2BridgeDAL,
|
||||
secretVersionTagV2BridgeDAL,
|
||||
resourceMetadataDAL
|
||||
}: TSecretSyncQueueFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const integrationMeter = opentelemetry.metrics.getMeter("SecretSyncs");
|
||||
const syncSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_sync_secrets_errors", {
|
||||
description: "Secret Sync - sync secrets errors",
|
||||
unit: "1"
|
||||
});
|
||||
const importSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_import_secrets_errors", {
|
||||
description: "Secret Sync - import secrets errors",
|
||||
unit: "1"
|
||||
});
|
||||
const removeSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_remove_secrets_errors", {
|
||||
description: "Secret Sync - remove secrets errors",
|
||||
unit: "1"
|
||||
});
|
||||
|
||||
const $createManySecretsRawFn = createManySecretsRawFnFactory({
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
secretDAL,
|
||||
secretVersionDAL,
|
||||
secretBlindIndexDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
folderDAL,
|
||||
kmsService,
|
||||
secretVersionV2BridgeDAL,
|
||||
secretV2BridgeDAL,
|
||||
secretVersionTagV2BridgeDAL,
|
||||
resourceMetadataDAL
|
||||
});
|
||||
|
||||
const $updateManySecretsRawFn = updateManySecretsRawFnFactory({
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
secretDAL,
|
||||
secretVersionDAL,
|
||||
secretBlindIndexDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
folderDAL,
|
||||
kmsService,
|
||||
secretVersionV2BridgeDAL,
|
||||
secretV2BridgeDAL,
|
||||
secretVersionTagV2BridgeDAL,
|
||||
resourceMetadataDAL
|
||||
});
|
||||
|
||||
const $getInfisicalSecrets = async (
|
||||
secretSync: TSecretSyncRaw | TSecretSyncWithCredentials,
|
||||
includeImports = true
|
||||
) => {
|
||||
const { projectId, folderId, environment, folder } = secretSync;
|
||||
|
||||
if (!folderId || !environment || !folder)
|
||||
throw new SecretSyncError({
|
||||
message:
|
||||
"Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path.",
|
||||
shouldRetry: false
|
||||
});
|
||||
|
||||
const secretMap: TSecretMap = {};
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptSecretValue = (value?: Buffer | undefined | null) =>
|
||||
value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "";
|
||||
|
||||
const { expandSecretReferences } = expandSecretReferencesFactory({
|
||||
decryptSecretValue,
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
folderDAL,
|
||||
projectId,
|
||||
canExpandValue: () => true
|
||||
});
|
||||
|
||||
const secrets = await secretV2BridgeDAL.findByFolderId(folderId);
|
||||
|
||||
await Promise.allSettled(
|
||||
secrets.map(async (secret) => {
|
||||
const secretKey = secret.key;
|
||||
const secretValue = decryptSecretValue(secret.encryptedValue);
|
||||
const expandedSecretValue = await expandSecretReferences({
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
value: secretValue
|
||||
});
|
||||
secretMap[secretKey] = { value: expandedSecretValue || "" };
|
||||
|
||||
if (secret.encryptedComment) {
|
||||
const commentValue = decryptSecretValue(secret.encryptedComment);
|
||||
secretMap[secretKey].comment = commentValue;
|
||||
}
|
||||
|
||||
secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
|
||||
})
|
||||
);
|
||||
|
||||
if (!includeImports) return secretMap;
|
||||
|
||||
const secretImports = await secretImportDAL.find({ folderId, isReplication: false });
|
||||
|
||||
if (secretImports.length) {
|
||||
const importedSecrets = await fnSecretsV2FromImports({
|
||||
decryptor: decryptSecretValue,
|
||||
folderDAL,
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
expandSecretReferences,
|
||||
secretImportDAL,
|
||||
secretImports,
|
||||
hasSecretAccess: () => true
|
||||
});
|
||||
|
||||
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) {
|
||||
const importedSecret = importedSecrets[i].secrets[j];
|
||||
if (!secretMap[importedSecret.key]) {
|
||||
secretMap[importedSecret.key] = {
|
||||
skipMultilineEncoding: importedSecret.skipMultilineEncoding,
|
||||
comment: importedSecret.secretComment,
|
||||
value: importedSecret.secretValue || ""
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secretMap;
|
||||
};
|
||||
|
||||
const queueSecretSyncSyncSecretsById = async (payload: TQueueSecretSyncSyncSecretsByIdDTO) =>
|
||||
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncSyncSecrets, payload, {
|
||||
delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
});
|
||||
|
||||
const queueSecretSyncImportSecretsById = async (payload: TQueueSecretSyncImportSecretsByIdDTO) =>
|
||||
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncImportSecrets, payload, {
|
||||
attempts: 1,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
});
|
||||
|
||||
const queueSecretSyncRemoveSecretsById = async (payload: TQueueSecretSyncRemoveSecretsByIdDTO) =>
|
||||
queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncRemoveSecrets, payload, {
|
||||
attempts: 1,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
});
|
||||
|
||||
const $queueSendSecretSyncFailedNotifications = async (payload: TQueueSendSecretSyncActionFailedNotificationsDTO) => {
|
||||
if (!appCfg.isSmtpConfigured) return;
|
||||
|
||||
await queueService.queue(
|
||||
QueueName.AppConnectionSecretSync,
|
||||
QueueJobs.SecretSyncSendActionFailedNotifications,
|
||||
payload,
|
||||
{
|
||||
jobId: `secret-sync-${payload.secretSync.id}-failed-notifications`,
|
||||
attempts: 5,
|
||||
delay: 1000 * 60,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnFail: true,
|
||||
removeOnComplete: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const $importSecrets = async (
|
||||
secretSync: TSecretSyncWithCredentials,
|
||||
importBehavior: SecretSyncImportBehavior
|
||||
): Promise<TSecretMap> => {
|
||||
const { projectId, environment, folder } = secretSync;
|
||||
|
||||
if (!environment || !folder)
|
||||
throw new Error(
|
||||
"Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path."
|
||||
);
|
||||
|
||||
const importedSecrets = await SecretSyncFns.getSecrets(secretSync);
|
||||
|
||||
if (!Object.keys(importedSecrets).length) return {};
|
||||
|
||||
const importedSecretMap: TSecretMap = {};
|
||||
|
||||
const secretMap = await $getInfisicalSecrets(secretSync, false);
|
||||
|
||||
const secretsToCreate: Parameters<typeof $createManySecretsRawFn>[0]["secrets"] = [];
|
||||
const secretsToUpdate: Parameters<typeof $updateManySecretsRawFn>[0]["secrets"] = [];
|
||||
|
||||
Object.entries(importedSecrets).forEach(([key, secretData]) => {
|
||||
const { value, comment = "", skipMultilineEncoding } = secretData;
|
||||
|
||||
const secret = {
|
||||
secretName: key,
|
||||
secretValue: value,
|
||||
type: SecretType.Shared,
|
||||
secretComment: comment,
|
||||
skipMultilineEncoding: skipMultilineEncoding ?? undefined
|
||||
};
|
||||
|
||||
if (Object.hasOwn(secretMap, key)) {
|
||||
secretsToUpdate.push(secret);
|
||||
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData;
|
||||
} else {
|
||||
secretsToCreate.push(secret);
|
||||
importedSecretMap[key] = secretData;
|
||||
}
|
||||
});
|
||||
|
||||
if (secretsToCreate.length) {
|
||||
await $createManySecretsRawFn({
|
||||
projectId,
|
||||
path: folder.path,
|
||||
environment: environment.slug,
|
||||
secrets: secretsToCreate
|
||||
});
|
||||
}
|
||||
|
||||
if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination && secretsToUpdate.length) {
|
||||
await $updateManySecretsRawFn({
|
||||
projectId,
|
||||
path: folder.path,
|
||||
environment: environment.slug,
|
||||
secrets: secretsToUpdate
|
||||
});
|
||||
}
|
||||
|
||||
return importedSecretMap;
|
||||
};
|
||||
|
||||
const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => {
|
||||
const {
|
||||
data: { syncId, auditLogInfo }
|
||||
} = job;
|
||||
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
|
||||
|
||||
await secretSyncDAL.updateById(syncId, {
|
||||
syncStatus: SecretSyncStatus.Running
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`SecretSync Sync [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||
);
|
||||
|
||||
let isSynced = false;
|
||||
let syncMessage: string | null = null;
|
||||
let isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||
|
||||
try {
|
||||
const {
|
||||
connection: { orgId, encryptedCredentials }
|
||||
} = secretSync;
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId,
|
||||
encryptedCredentials,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const secretSyncWithCredentials = {
|
||||
...secretSync,
|
||||
connection: {
|
||||
...secretSync.connection,
|
||||
credentials
|
||||
}
|
||||
} as TSecretSyncWithCredentials;
|
||||
|
||||
const {
|
||||
lastSyncedAt,
|
||||
syncOptions: { initialSyncBehavior }
|
||||
} = secretSyncWithCredentials;
|
||||
|
||||
const secretMap = await $getInfisicalSecrets(secretSync);
|
||||
|
||||
if (!lastSyncedAt && initialSyncBehavior !== SecretSyncInitialSyncBehavior.OverwriteDestination) {
|
||||
const importedSecretMap = await $importSecrets(
|
||||
secretSyncWithCredentials,
|
||||
initialSyncBehavior === SecretSyncInitialSyncBehavior.ImportPrioritizeSource
|
||||
? SecretSyncImportBehavior.PrioritizeSource
|
||||
: SecretSyncImportBehavior.PrioritizeDestination
|
||||
);
|
||||
|
||||
Object.entries(importedSecretMap).forEach(([key, secretData]) => {
|
||||
secretMap[key] = secretData;
|
||||
});
|
||||
}
|
||||
|
||||
await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap);
|
||||
|
||||
isSynced = true;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`SecretSync Sync Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||
);
|
||||
|
||||
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||
syncSecretsErrorHistogram.record(1, {
|
||||
version: 1,
|
||||
destination: secretSync.destination,
|
||||
syncId: secretSync.id,
|
||||
projectId: secretSync.projectId,
|
||||
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||
name: err instanceof Error ? err.name : undefined
|
||||
});
|
||||
}
|
||||
|
||||
syncMessage = parseSyncErrorMessage(err);
|
||||
|
||||
if (err instanceof SecretSyncError && !err.shouldRetry) {
|
||||
isFinalAttempt = true;
|
||||
} else {
|
||||
// re-throw so job fails
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
const ranAt = new Date();
|
||||
const syncStatus = isSynced ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: secretSync.projectId,
|
||||
...(auditLogInfo ?? {
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
}
|
||||
}),
|
||||
event: {
|
||||
type: EventType.SECRET_SYNC_SYNC_SECRETS,
|
||||
metadata: {
|
||||
syncId: secretSync.id,
|
||||
syncOptions: secretSync.syncOptions,
|
||||
destination: secretSync.destination,
|
||||
destinationConfig: secretSync.destinationConfig,
|
||||
folderId: secretSync.folderId,
|
||||
connectionId: secretSync.connectionId,
|
||||
jobRanAt: ranAt,
|
||||
jobId: job.id!,
|
||||
syncStatus,
|
||||
syncMessage
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSynced || isFinalAttempt) {
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
|
||||
syncStatus,
|
||||
lastSyncJobId: job.id,
|
||||
lastSyncMessage: syncMessage,
|
||||
lastSyncedAt: isSynced ? ranAt : undefined
|
||||
});
|
||||
|
||||
if (!isSynced) {
|
||||
await $queueSendSecretSyncFailedNotifications({
|
||||
secretSync: updatedSecretSync,
|
||||
action: SecretSyncAction.SyncSecrets,
|
||||
auditLogInfo
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("SecretSync Sync Job with ID %s Completed", job.id);
|
||||
};
|
||||
|
||||
const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => {
|
||||
const {
|
||||
data: { syncId, auditLogInfo, importBehavior }
|
||||
} = job;
|
||||
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
|
||||
|
||||
await secretSyncDAL.updateById(syncId, {
|
||||
importStatus: SecretSyncStatus.Running
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`SecretSync Import [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||
);
|
||||
|
||||
let isSuccess = false;
|
||||
let importMessage: string | null = null;
|
||||
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||
|
||||
try {
|
||||
const {
|
||||
connection: { orgId, encryptedCredentials }
|
||||
} = secretSync;
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId,
|
||||
encryptedCredentials,
|
||||
kmsService
|
||||
});
|
||||
|
||||
await $importSecrets(
|
||||
{
|
||||
...secretSync,
|
||||
connection: {
|
||||
...secretSync.connection,
|
||||
credentials
|
||||
}
|
||||
} as TSecretSyncWithCredentials,
|
||||
importBehavior
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`SecretSync Import Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||
);
|
||||
|
||||
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||
importSecretsErrorHistogram.record(1, {
|
||||
version: 1,
|
||||
destination: secretSync.destination,
|
||||
syncId: secretSync.id,
|
||||
projectId: secretSync.projectId,
|
||||
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||
name: err instanceof Error ? err.name : undefined
|
||||
});
|
||||
}
|
||||
|
||||
importMessage = parseSyncErrorMessage(err);
|
||||
|
||||
// re-throw so job fails
|
||||
throw err;
|
||||
} finally {
|
||||
const ranAt = new Date();
|
||||
const importStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: secretSync.projectId,
|
||||
...(auditLogInfo ?? {
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
}
|
||||
}),
|
||||
event: {
|
||||
type: EventType.SECRET_SYNC_IMPORT_SECRETS,
|
||||
metadata: {
|
||||
syncId: secretSync.id,
|
||||
syncOptions: secretSync.syncOptions,
|
||||
destination: secretSync.destination,
|
||||
destinationConfig: secretSync.destinationConfig,
|
||||
folderId: secretSync.folderId,
|
||||
connectionId: secretSync.connectionId,
|
||||
jobRanAt: ranAt,
|
||||
jobId: job.id!,
|
||||
importStatus,
|
||||
importMessage,
|
||||
importBehavior
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSuccess || isFinalAttempt) {
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
|
||||
importStatus,
|
||||
lastImportJobId: job.id,
|
||||
lastImportMessage: importMessage,
|
||||
lastImportedAt: isSuccess ? ranAt : undefined
|
||||
});
|
||||
|
||||
if (!isSuccess) {
|
||||
await $queueSendSecretSyncFailedNotifications({
|
||||
secretSync: updatedSecretSync,
|
||||
action: SecretSyncAction.ImportSecrets,
|
||||
auditLogInfo
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("SecretSync Import Job with ID %s Completed", job.id);
|
||||
};
|
||||
|
||||
const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => {
|
||||
const {
|
||||
data: { syncId, auditLogInfo, deleteSyncOnComplete }
|
||||
} = job;
|
||||
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
|
||||
|
||||
await secretSyncDAL.updateById(syncId, {
|
||||
removeStatus: SecretSyncStatus.Running
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`SecretSync Remove [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||
);
|
||||
|
||||
let isSuccess = false;
|
||||
let removeMessage: string | null = null;
|
||||
const isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||
|
||||
try {
|
||||
const {
|
||||
connection: { orgId, encryptedCredentials }
|
||||
} = secretSync;
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId,
|
||||
encryptedCredentials,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const secretMap = await $getInfisicalSecrets(secretSync);
|
||||
|
||||
await SecretSyncFns.removeSecrets(
|
||||
{
|
||||
...secretSync,
|
||||
connection: {
|
||||
...secretSync.connection,
|
||||
credentials
|
||||
}
|
||||
} as TSecretSyncWithCredentials,
|
||||
secretMap
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`SecretSync Remove Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]`
|
||||
);
|
||||
|
||||
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||
removeSecretsErrorHistogram.record(1, {
|
||||
version: 1,
|
||||
destination: secretSync.destination,
|
||||
syncId: secretSync.id,
|
||||
projectId: secretSync.projectId,
|
||||
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||
name: err instanceof Error ? err.name : undefined
|
||||
});
|
||||
}
|
||||
|
||||
removeMessage = parseSyncErrorMessage(err);
|
||||
|
||||
// re-throw so job fails
|
||||
throw err;
|
||||
} finally {
|
||||
const ranAt = new Date();
|
||||
const removeStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed;
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: secretSync.projectId,
|
||||
...(auditLogInfo ?? {
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
}
|
||||
}),
|
||||
event: {
|
||||
type: EventType.SECRET_SYNC_REMOVE_SECRETS,
|
||||
metadata: {
|
||||
syncId: secretSync.id,
|
||||
syncOptions: secretSync.syncOptions,
|
||||
destination: secretSync.destination,
|
||||
destinationConfig: secretSync.destinationConfig,
|
||||
folderId: secretSync.folderId,
|
||||
connectionId: secretSync.connectionId,
|
||||
jobRanAt: ranAt,
|
||||
jobId: job.id!,
|
||||
removeStatus,
|
||||
removeMessage
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSuccess || isFinalAttempt) {
|
||||
if (isSuccess && deleteSyncOnComplete) {
|
||||
await secretSyncDAL.deleteById(secretSync.id);
|
||||
} else {
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, {
|
||||
removeStatus,
|
||||
lastRemoveJobId: job.id,
|
||||
lastRemoveMessage: removeMessage,
|
||||
lastRemovedAt: isSuccess ? ranAt : undefined
|
||||
});
|
||||
|
||||
if (!isSuccess) {
|
||||
await $queueSendSecretSyncFailedNotifications({
|
||||
secretSync: updatedSecretSync,
|
||||
action: SecretSyncAction.RemoveSecrets,
|
||||
auditLogInfo
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("SecretSync Remove Job with ID %s Completed", job.id);
|
||||
};
|
||||
|
||||
const $sendSecretSyncFailedNotifications = async (job: TSendSecretSyncFailedNotificationsJobDTO) => {
|
||||
const {
|
||||
data: { secretSync, auditLogInfo, action }
|
||||
} = job;
|
||||
|
||||
const { projectId, destination, name, folder, lastSyncMessage, lastRemoveMessage, lastImportMessage, environment } =
|
||||
secretSync;
|
||||
|
||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
let projectAdmins = projectMembers.filter((member) =>
|
||||
member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
|
||||
);
|
||||
|
||||
const triggeredByUserId =
|
||||
auditLogInfo && auditLogInfo.actor.type === ActorType.USER && auditLogInfo.actor.metadata.userId;
|
||||
|
||||
// only notify triggering user if triggered by admin
|
||||
if (triggeredByUserId && projectAdmins.map((admin) => admin.userId).includes(triggeredByUserId)) {
|
||||
projectAdmins = projectAdmins.filter((admin) => admin.userId === triggeredByUserId);
|
||||
}
|
||||
|
||||
const syncDestination = SECRET_SYNC_NAME_MAP[destination as SecretSync];
|
||||
|
||||
let actionLabel: string;
|
||||
let failureMessage: string | null | undefined;
|
||||
|
||||
switch (action) {
|
||||
case SecretSyncAction.ImportSecrets:
|
||||
actionLabel = "Import";
|
||||
failureMessage = lastImportMessage;
|
||||
|
||||
break;
|
||||
case SecretSyncAction.RemoveSecrets:
|
||||
actionLabel = "Remove";
|
||||
failureMessage = lastRemoveMessage;
|
||||
|
||||
break;
|
||||
case SecretSyncAction.SyncSecrets:
|
||||
default:
|
||||
actionLabel = `Sync`;
|
||||
failureMessage = lastSyncMessage;
|
||||
break;
|
||||
}
|
||||
|
||||
await smtpService.sendMail({
|
||||
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
|
||||
template: SmtpTemplates.SecretSyncFailed,
|
||||
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
|
||||
substitutions: {
|
||||
syncName: name,
|
||||
syncDestination,
|
||||
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
|
||||
failureMessage,
|
||||
secretPath: folder?.path,
|
||||
environment: environment?.name,
|
||||
projectName: project.name,
|
||||
syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}`
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const queueSecretSyncsSyncSecretsByPath = async ({
|
||||
secretPath,
|
||||
projectId,
|
||||
environmentSlug
|
||||
}: TQueueSecretSyncsByPathDTO) => {
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath);
|
||||
|
||||
if (!folder)
|
||||
throw new Error(
|
||||
`Could not find folder at path "${secretPath}" for environment with slug "${environmentSlug}" in project with ID "${projectId}"`
|
||||
);
|
||||
|
||||
const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true });
|
||||
|
||||
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id })));
|
||||
};
|
||||
|
||||
const $handleAcquireLockFailure = async (job: SecretSyncActionJob) => {
|
||||
const { syncId, auditLogInfo } = job.data;
|
||||
|
||||
switch (job.name) {
|
||||
case QueueJobs.SecretSyncSyncSecrets: {
|
||||
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO;
|
||||
|
||||
if (failedToAcquireLockCount < 10) {
|
||||
await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const secretSync = await secretSyncDAL.updateById(syncId, {
|
||||
syncStatus: SecretSyncStatus.Failed,
|
||||
lastSyncMessage:
|
||||
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||
lastSyncJobId: job.id
|
||||
});
|
||||
|
||||
await $queueSendSecretSyncFailedNotifications({
|
||||
secretSync,
|
||||
action: SecretSyncAction.SyncSecrets,
|
||||
auditLogInfo
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
// Scott: the two cases below are unlikely to happen as we check the lock at the API level but including this as a fallback
|
||||
case QueueJobs.SecretSyncImportSecrets: {
|
||||
const secretSync = await secretSyncDAL.updateById(syncId, {
|
||||
importStatus: SecretSyncStatus.Failed,
|
||||
lastImportMessage:
|
||||
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||
lastImportJobId: job.id
|
||||
});
|
||||
|
||||
await $queueSendSecretSyncFailedNotifications({
|
||||
secretSync,
|
||||
action: SecretSyncAction.ImportSecrets,
|
||||
auditLogInfo
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case QueueJobs.SecretSyncRemoveSecrets: {
|
||||
const secretSync = await secretSyncDAL.updateById(syncId, {
|
||||
removeStatus: SecretSyncStatus.Failed,
|
||||
lastRemoveMessage:
|
||||
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||
lastRemoveJobId: job.id
|
||||
});
|
||||
|
||||
await $queueSendSecretSyncFailedNotifications({
|
||||
secretSync,
|
||||
action: SecretSyncAction.RemoveSecrets,
|
||||
auditLogInfo
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new Error(`Unhandled Secret Sync Job ${job.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
queueService.start(QueueName.AppConnectionSecretSync, async (job) => {
|
||||
if (job.name === QueueJobs.SecretSyncSendActionFailedNotifications) {
|
||||
await $sendSecretSyncFailedNotifications(job as TSendSecretSyncFailedNotificationsJobDTO);
|
||||
return;
|
||||
}
|
||||
|
||||
const { syncId } = job.data as
|
||||
| TQueueSecretSyncSyncSecretsByIdDTO
|
||||
| TQueueSecretSyncImportSecretsByIdDTO
|
||||
| TQueueSecretSyncRemoveSecretsByIdDTO;
|
||||
|
||||
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
|
||||
|
||||
try {
|
||||
lock = await keyStore.acquireLock(
|
||||
[KeyStorePrefixes.SecretSyncLock(syncId)],
|
||||
// scott: not sure on this duration; syncs can take excessive amounts of time so we need to keep it locked,
|
||||
// but should always release below...
|
||||
5 * 60 * 1000
|
||||
);
|
||||
} catch (e) {
|
||||
logger.info(`SecretSync Failed to acquire lock [syncId=${syncId}] [job=${job.name}]`);
|
||||
|
||||
await $handleAcquireLockFailure(job as SecretSyncActionJob);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (job.name) {
|
||||
case QueueJobs.SecretSyncSyncSecrets:
|
||||
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO);
|
||||
break;
|
||||
case QueueJobs.SecretSyncImportSecrets:
|
||||
await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO);
|
||||
break;
|
||||
case QueueJobs.SecretSyncRemoveSecrets:
|
||||
await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO);
|
||||
break;
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new Error(`Unhandled Secret Sync Job ${job.name}`);
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
queueSecretSyncSyncSecretsById,
|
||||
queueSecretSyncImportSecretsById,
|
||||
queueSecretSyncRemoveSecretsById,
|
||||
queueSecretSyncsSyncSecretsByPath
|
||||
};
|
||||
};
|
96
backend/src/services/secret-sync/secret-sync-schemas.ts
Normal file
96
backend/src/services/secret-sync/secret-sync-schemas.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs";
|
||||
import { SecretSyncs } from "@app/lib/api-docs";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps";
|
||||
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) =>
|
||||
z.object({
|
||||
initialSyncBehavior: (options.canImportSecrets
|
||||
? z.nativeEnum(SecretSyncInitialSyncBehavior)
|
||||
: z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination)
|
||||
).describe(SecretSyncs.SYNC_OPTIONS(secretSync).INITIAL_SYNC_BEHAVIOR)
|
||||
// prependPrefix: z
|
||||
// .string()
|
||||
// .trim()
|
||||
// .transform((str) => str.toUpperCase())
|
||||
// .optional()
|
||||
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX),
|
||||
// appendSuffix: z
|
||||
// .string()
|
||||
// .trim()
|
||||
// .transform((str) => str.toUpperCase())
|
||||
// .optional()
|
||||
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX)
|
||||
});
|
||||
|
||||
export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
|
||||
SecretSyncsSchema.omit({
|
||||
destination: true,
|
||||
destinationConfig: true,
|
||||
syncOptions: true
|
||||
}).extend({
|
||||
// destination needs to be on the extended object for type differentiation
|
||||
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig),
|
||||
// join properties
|
||||
projectId: z.string(),
|
||||
connection: z.object({
|
||||
app: z.literal(SECRET_SYNC_CONNECTION_MAP[destination]),
|
||||
name: z.string(),
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }).nullable(),
|
||||
folder: z.object({ id: z.string(), path: z.string() }).nullable()
|
||||
});
|
||||
|
||||
export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
|
||||
z.object({
|
||||
name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name),
|
||||
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(256, "Description cannot exceed 256 characters")
|
||||
.nullish()
|
||||
.describe(SecretSyncs.CREATE(destination).description),
|
||||
connectionId: z.string().uuid().describe(SecretSyncs.CREATE(destination).connectionId),
|
||||
environment: slugSchema({ field: "environment", max: 64 }).describe(SecretSyncs.CREATE(destination).environment),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Secret path required")
|
||||
.transform(removeTrailingSlash)
|
||||
.describe(SecretSyncs.CREATE(destination).secretPath),
|
||||
isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled),
|
||||
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions)
|
||||
});
|
||||
|
||||
export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
|
||||
z.object({
|
||||
name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(),
|
||||
connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(256, "Description cannot exceed 256 characters")
|
||||
.nullish()
|
||||
.describe(SecretSyncs.UPDATE(destination).description),
|
||||
environment: slugSchema({ field: "environment", max: 64 })
|
||||
.optional()
|
||||
.describe(SecretSyncs.UPDATE(destination).environment),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Invalid secret path")
|
||||
.transform(removeTrailingSlash)
|
||||
.optional()
|
||||
.describe(SecretSyncs.UPDATE(destination).secretPath),
|
||||
isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled),
|
||||
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig)
|
||||
.optional()
|
||||
.describe(SecretSyncs.UPDATE(destination).syncOptions)
|
||||
});
|
562
backend/src/services/secret-sync/secret-sync-service.ts
Normal file
562
backend/src/services/secret-sync/secret-sync-service.ts
Normal file
@ -0,0 +1,562 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSecretSyncActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import {
|
||||
SecretSyncStatus,
|
||||
TCreateSecretSyncDTO,
|
||||
TDeleteSecretSyncDTO,
|
||||
TFindSecretSyncByIdDTO,
|
||||
TFindSecretSyncByNameDTO,
|
||||
TListSecretSyncsByProjectId,
|
||||
TSecretSync,
|
||||
TTriggerSecretSyncImportSecretsByIdDTO,
|
||||
TTriggerSecretSyncRemoveSecretsByIdDTO,
|
||||
TTriggerSecretSyncSyncSecretsByIdDTO,
|
||||
TUpdateSecretSyncDTO
|
||||
} from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
import { TSecretSyncDALFactory } from "./secret-sync-dal";
|
||||
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
|
||||
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
|
||||
|
||||
type TSecretSyncServiceFactoryDep = {
|
||||
secretSyncDAL: TSecretSyncDALFactory;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem">;
|
||||
secretSyncQueue: Pick<
|
||||
TSecretSyncQueueFactory,
|
||||
"queueSecretSyncSyncSecretsById" | "queueSecretSyncImportSecretsById" | "queueSecretSyncRemoveSecretsById"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFactory>;
|
||||
|
||||
export const secretSyncServiceFactory = ({
|
||||
secretSyncDAL,
|
||||
folderDAL,
|
||||
permissionService,
|
||||
appConnectionService,
|
||||
projectBotService,
|
||||
secretSyncQueue,
|
||||
keyStore
|
||||
}: TSecretSyncServiceFactoryDep) => {
|
||||
const listSecretSyncsByProjectId = async (
|
||||
{ projectId, destination }: TListSecretSyncsByProjectId,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
const secretSyncs = await secretSyncDAL.find({
|
||||
...(destination && { destination }),
|
||||
projectId
|
||||
});
|
||||
|
||||
return secretSyncs as TSecretSync[];
|
||||
};
|
||||
|
||||
const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => {
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
return secretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const findSecretSyncByName = async (
|
||||
{ destination, syncName, projectId }: TFindSecretSyncByNameDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const folders = await folderDAL.findByProjectId(projectId);
|
||||
|
||||
// we prevent conflicting names within a project so this will only return one at most
|
||||
const [secretSync] = await secretSyncDAL.find({
|
||||
name: syncName,
|
||||
$in: {
|
||||
folderId: folders.map((folder) => folder.id)
|
||||
}
|
||||
});
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with name "${syncName}"`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
return secretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const createSecretSync = async (
|
||||
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const { permission: projectPermission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
if (!shouldUseSecretV2Bridge)
|
||||
throw new BadRequestError({ message: "Project version does not support Secret Syncs" });
|
||||
|
||||
ForbiddenError.from(projectPermission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Create,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
ForbiddenError.from(projectPermission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath
|
||||
})
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
|
||||
if (!folder)
|
||||
throw new BadRequestError({
|
||||
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
|
||||
});
|
||||
|
||||
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
|
||||
|
||||
// validates permission to connect and app is valid for sync destination
|
||||
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
|
||||
|
||||
const secretSync = await secretSyncDAL.transaction(async (tx) => {
|
||||
const isConflictingName = Boolean(
|
||||
(
|
||||
await secretSyncDAL.find(
|
||||
{
|
||||
name: params.name,
|
||||
projectId
|
||||
},
|
||||
tx
|
||||
)
|
||||
).length
|
||||
);
|
||||
|
||||
if (isConflictingName)
|
||||
throw new BadRequestError({
|
||||
message: `A Secret Sync with the name "${params.name}" already exists for the project with ID "${folder.projectId}"`
|
||||
});
|
||||
|
||||
const sync = await secretSyncDAL.create({
|
||||
folderId: folder.id,
|
||||
...params,
|
||||
...(params.isAutoSyncEnabled && { syncStatus: SecretSyncStatus.Pending }),
|
||||
projectId
|
||||
});
|
||||
|
||||
return sync;
|
||||
});
|
||||
|
||||
if (secretSync.isAutoSyncEnabled) await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id });
|
||||
|
||||
return secretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const updateSecretSync = async (
|
||||
{ destination, syncId, secretPath, environment, ...params }: TUpdateSecretSyncDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID ${syncId}`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Edit,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
const updatedSecretSync = await secretSyncDAL.transaction(async (tx) => {
|
||||
let { folderId } = secretSync;
|
||||
|
||||
if (params.connectionId) {
|
||||
const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];
|
||||
|
||||
// validates permission to connect and app is valid for sync destination
|
||||
await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor);
|
||||
}
|
||||
|
||||
if (
|
||||
(secretPath && secretPath !== secretSync.folder?.path) ||
|
||||
(environment && environment !== secretSync.environment?.slug)
|
||||
) {
|
||||
const updatedEnvironment = environment ?? secretSync.environment?.slug;
|
||||
const updatedSecretPath = secretPath ?? secretSync.folder?.path;
|
||||
|
||||
if (!updatedEnvironment || !updatedSecretPath)
|
||||
throw new BadRequestError({ message: "Must specify both source environment and secret path" });
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: updatedEnvironment,
|
||||
secretPath: updatedSecretPath
|
||||
})
|
||||
);
|
||||
|
||||
const newFolder = await folderDAL.findBySecretPath(secretSync.projectId, updatedEnvironment, updatedSecretPath);
|
||||
|
||||
if (!newFolder)
|
||||
throw new BadRequestError({
|
||||
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${secretSync.projectId}"`
|
||||
});
|
||||
|
||||
folderId = newFolder.id;
|
||||
}
|
||||
|
||||
if (params.name && secretSync.name !== params.name) {
|
||||
const isConflictingName = Boolean(
|
||||
(
|
||||
await secretSyncDAL.find(
|
||||
{
|
||||
name: params.name,
|
||||
projectId: secretSync.projectId
|
||||
},
|
||||
tx
|
||||
)
|
||||
).length
|
||||
);
|
||||
|
||||
if (isConflictingName)
|
||||
throw new BadRequestError({
|
||||
message: `A Secret Sync with the name "${params.name}" already exists for project with ID "${secretSync.projectId}"`
|
||||
});
|
||||
}
|
||||
|
||||
const isAutoSyncEnabled = params.isAutoSyncEnabled ?? secretSync.isAutoSyncEnabled;
|
||||
|
||||
const updatedSync = await secretSyncDAL.updateById(syncId, {
|
||||
...params,
|
||||
...(isAutoSyncEnabled && folderId && { syncStatus: SecretSyncStatus.Pending }),
|
||||
folderId
|
||||
});
|
||||
|
||||
return updatedSync;
|
||||
});
|
||||
|
||||
if (updatedSecretSync.isAutoSyncEnabled)
|
||||
await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id });
|
||||
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const deleteSecretSync = async (
|
||||
{ destination, syncId, removeSecrets }: TDeleteSecretSyncDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Delete,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
if (removeSecrets) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.RemoveSecrets,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (!secretSync.folderId)
|
||||
throw new BadRequestError({
|
||||
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||
});
|
||||
|
||||
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||
|
||||
if (isSyncJobRunning)
|
||||
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||
|
||||
await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, deleteSyncOnComplete: true });
|
||||
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||
removeStatus: SecretSyncStatus.Pending
|
||||
});
|
||||
|
||||
return updatedSecretSync;
|
||||
}
|
||||
|
||||
await secretSyncDAL.deleteById(syncId);
|
||||
|
||||
return secretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const triggerSecretSyncSyncSecretsById = async (
|
||||
{ syncId, destination, ...params }: TTriggerSecretSyncSyncSecretsByIdDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.SyncSecrets,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
if (!secretSync.folderId)
|
||||
throw new BadRequestError({
|
||||
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||
});
|
||||
|
||||
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||
|
||||
if (isSyncJobRunning)
|
||||
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||
|
||||
await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId, ...params });
|
||||
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||
syncStatus: SecretSyncStatus.Pending
|
||||
});
|
||||
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const triggerSecretSyncImportSecretsById = async (
|
||||
{ syncId, destination, ...params }: TTriggerSecretSyncImportSecretsByIdDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
if (!listSecretSyncOptions().find((option) => option.destination === destination)?.canImportSecrets) {
|
||||
throw new BadRequestError({
|
||||
message: `${SECRET_SYNC_NAME_MAP[destination]} does not support importing secrets.`
|
||||
});
|
||||
}
|
||||
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.ImportSecrets,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
if (!secretSync.folderId)
|
||||
throw new BadRequestError({
|
||||
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||
});
|
||||
|
||||
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||
|
||||
if (isSyncJobRunning)
|
||||
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||
|
||||
await secretSyncQueue.queueSecretSyncImportSecretsById({ syncId, ...params });
|
||||
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||
importStatus: SecretSyncStatus.Pending
|
||||
});
|
||||
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const triggerSecretSyncRemoveSecretsById = async (
|
||||
{ syncId, destination, ...params }: TTriggerSecretSyncRemoveSecretsByIdDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const secretSync = await secretSyncDAL.findById(syncId);
|
||||
|
||||
if (!secretSync)
|
||||
throw new NotFoundError({
|
||||
message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"`
|
||||
});
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId: secretSync.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.RemoveSecrets,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination])
|
||||
throw new BadRequestError({
|
||||
message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}`
|
||||
});
|
||||
|
||||
if (!secretSync.folderId)
|
||||
throw new BadRequestError({
|
||||
message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.`
|
||||
});
|
||||
|
||||
const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId)));
|
||||
|
||||
if (isSyncJobRunning)
|
||||
throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` });
|
||||
|
||||
await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, ...params });
|
||||
|
||||
const updatedSecretSync = await secretSyncDAL.updateById(syncId, {
|
||||
removeStatus: SecretSyncStatus.Pending
|
||||
});
|
||||
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
return {
|
||||
listSecretSyncOptions,
|
||||
listSecretSyncsByProjectId,
|
||||
findSecretSyncById,
|
||||
findSecretSyncByName,
|
||||
createSecretSync,
|
||||
updateSecretSync,
|
||||
deleteSecretSync,
|
||||
triggerSecretSyncSyncSecretsById,
|
||||
triggerSecretSyncImportSecretsById,
|
||||
triggerSecretSyncRemoveSecretsById
|
||||
};
|
||||
};
|
148
backend/src/services/secret-sync/secret-sync-types.ts
Normal file
148
backend/src/services/secret-sync/secret-sync-types.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { Job } from "bullmq";
|
||||
|
||||
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { QueueJobs } from "@app/queue";
|
||||
import {
|
||||
TGitHubSync,
|
||||
TGitHubSyncInput,
|
||||
TGitHubSyncListItem,
|
||||
TGitHubSyncWithCredentials
|
||||
} from "@app/services/secret-sync/github";
|
||||
import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
|
||||
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
import {
|
||||
TAwsParameterStoreSync,
|
||||
TAwsParameterStoreSyncInput,
|
||||
TAwsParameterStoreSyncListItem,
|
||||
TAwsParameterStoreSyncWithCredentials
|
||||
} from "./aws-parameter-store";
|
||||
|
||||
export type TSecretSync = TAwsParameterStoreSync | TGitHubSync;
|
||||
|
||||
export type TSecretSyncWithCredentials = TAwsParameterStoreSyncWithCredentials | TGitHubSyncWithCredentials;
|
||||
|
||||
export type TSecretSyncInput = TAwsParameterStoreSyncInput | TGitHubSyncInput;
|
||||
|
||||
export type TSecretSyncListItem = TAwsParameterStoreSyncListItem | TGitHubSyncListItem;
|
||||
|
||||
export type TSyncOptionsConfig = {
|
||||
canImportSecrets: boolean;
|
||||
};
|
||||
|
||||
export type TListSecretSyncsByProjectId = {
|
||||
projectId: string;
|
||||
destination?: SecretSync;
|
||||
};
|
||||
|
||||
export type TFindSecretSyncByIdDTO = {
|
||||
syncId: string;
|
||||
destination: SecretSync;
|
||||
};
|
||||
|
||||
export type TFindSecretSyncByNameDTO = {
|
||||
syncName: string;
|
||||
projectId: string;
|
||||
destination: SecretSync;
|
||||
};
|
||||
|
||||
export type TCreateSecretSyncDTO = Pick<TSecretSync, "syncOptions" | "destinationConfig" | "name" | "connectionId"> & {
|
||||
destination: SecretSync;
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type TUpdateSecretSyncDTO = Partial<Omit<TCreateSecretSyncDTO, "projectId">> & {
|
||||
syncId: string;
|
||||
destination: SecretSync;
|
||||
};
|
||||
|
||||
export type TDeleteSecretSyncDTO = {
|
||||
destination: SecretSync;
|
||||
syncId: string;
|
||||
removeSecrets: boolean;
|
||||
};
|
||||
|
||||
type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">;
|
||||
|
||||
export enum SecretSyncStatus {
|
||||
Pending = "pending",
|
||||
Running = "running",
|
||||
Succeeded = "succeeded",
|
||||
Failed = "failed"
|
||||
}
|
||||
|
||||
export enum SecretSyncAction {
|
||||
SyncSecrets = "sync-secrets",
|
||||
ImportSecrets = "import-secrets",
|
||||
RemoveSecrets = "remove-secrets"
|
||||
}
|
||||
|
||||
export type TSecretSyncRaw = NonNullable<Awaited<ReturnType<TSecretSyncDALFactory["findById"]>>>;
|
||||
|
||||
export type TQueueSecretSyncsByPathDTO = {
|
||||
secretPath: string;
|
||||
environmentSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TQueueSecretSyncSyncSecretsByIdDTO = {
|
||||
syncId: string;
|
||||
failedToAcquireLockCount?: number;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TTriggerSecretSyncSyncSecretsByIdDTO = {
|
||||
destination: SecretSync;
|
||||
} & TQueueSecretSyncSyncSecretsByIdDTO;
|
||||
|
||||
export type TQueueSecretSyncImportSecretsByIdDTO = {
|
||||
syncId: string;
|
||||
importBehavior: SecretSyncImportBehavior;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TTriggerSecretSyncImportSecretsByIdDTO = {
|
||||
destination: SecretSync;
|
||||
} & TQueueSecretSyncImportSecretsByIdDTO;
|
||||
|
||||
export type TQueueSecretSyncRemoveSecretsByIdDTO = {
|
||||
syncId: string;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
deleteSyncOnComplete?: boolean;
|
||||
};
|
||||
|
||||
export type TTriggerSecretSyncRemoveSecretsByIdDTO = {
|
||||
destination: SecretSync;
|
||||
} & TQueueSecretSyncRemoveSecretsByIdDTO;
|
||||
|
||||
export type TQueueSendSecretSyncActionFailedNotificationsDTO = {
|
||||
secretSync: TSecretSyncRaw;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
action: SecretSyncAction;
|
||||
};
|
||||
|
||||
export type TSecretSyncSyncSecretsDTO = Job<TQueueSecretSyncSyncSecretsByIdDTO, void, QueueJobs.SecretSyncSyncSecrets>;
|
||||
export type TSecretSyncImportSecretsDTO = Job<
|
||||
TQueueSecretSyncImportSecretsByIdDTO,
|
||||
void,
|
||||
QueueJobs.SecretSyncSyncSecrets
|
||||
>;
|
||||
export type TSecretSyncRemoveSecretsDTO = Job<
|
||||
TQueueSecretSyncRemoveSecretsByIdDTO,
|
||||
void,
|
||||
QueueJobs.SecretSyncSyncSecrets
|
||||
>;
|
||||
|
||||
export type TSendSecretSyncFailedNotificationsJobDTO = Job<
|
||||
TQueueSendSecretSyncActionFailedNotificationsDTO,
|
||||
void,
|
||||
QueueJobs.SecretSyncSendActionFailedNotifications
|
||||
>;
|
||||
|
||||
export type TSecretMap = Record<
|
||||
string,
|
||||
{ value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined }
|
||||
>;
|
@ -29,6 +29,7 @@ import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@a
|
||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||
import { 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({
|
||||
|
@ -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"
|
||||
|
@ -0,0 +1,39 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>{{syncDestination}} Sync "{{syncName}}" Failed</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Infisical</h2>
|
||||
|
||||
<div>
|
||||
<p>{{content}}</p>
|
||||
<a href="{{syncUrl}}">
|
||||
View in Infisical.
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div>
|
||||
<p><strong>Name</strong>: {{syncName}}</p>
|
||||
<p><strong>Destination</strong>: {{syncDestination}}</p>
|
||||
<p><strong>Project</strong>: {{projectName}}</p>
|
||||
{{#if environment}}
|
||||
<p><strong>Environment</strong>: {{environment}}</p>
|
||||
{{/if}}
|
||||
{{#if secretPath}}
|
||||
<p><strong>Secret Path</strong>: {{secretPath}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if failureMessage}}
|
||||
<p><b>Reason: </b>{{failureMessage}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
@ -315,7 +315,11 @@ var loginCmd = &cobra.Command{
|
||||
credential, err := authStrategies[strategy](cmd, infisicalClient)
|
||||
|
||||
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 {
|
||||
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Available"
|
||||
openapi: "GET /api/v1/app-connections/aws/available"
|
||||
---
|
@ -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}"
|
||||
---
|
||||
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Available"
|
||||
openapi: "GET /api/v1/app-connections/github/available"
|
||||
---
|
@ -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}"
|
||||
---
|
||||
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/secret-syncs/aws-parameter-store"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/secret-syncs/aws-parameter-store/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by ID"
|
||||
openapi: "GET /api/v1/secret-syncs/aws-parameter-store/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by Name"
|
||||
openapi: "GET /api/v1/secret-syncs/aws-parameter-store/sync-name/{syncName}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Import Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/import-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v1/secret-syncs/aws-parameter-store"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Remove Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/remove-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Sync Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/sync-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/secret-syncs/aws-parameter-store/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/secret-syncs/github"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/secret-syncs/github/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by ID"
|
||||
openapi: "GET /api/v1/secret-syncs/github/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by Name"
|
||||
openapi: "GET /api/v1/secret-syncs/github/sync-name/{syncName}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v1/secret-syncs/github"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Remove Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/github/{syncId}/remove-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Sync Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/github/{syncId}/sync-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/secret-syncs/github/{syncId}"
|
||||
---
|
4
docs/api-reference/endpoints/secret-syncs/list.mdx
Normal file
4
docs/api-reference/endpoints/secret-syncs/list.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v1/secret-syncs"
|
||||
---
|
4
docs/api-reference/endpoints/secret-syncs/options.mdx
Normal file
4
docs/api-reference/endpoints/secret-syncs/options.mdx
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Options"
|
||||
openapi: "GET /api/v1/secret-syncs/options"
|
||||
---
|
@ -11,6 +11,7 @@ description: "Infisical CLI command overview"
|
||||
| `init` | Used to link a local project to the platform. |
|
||||
| `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 |
|
||||
|
BIN
docs/images/app-connections/aws/access-key-create-policy.png
Normal file
BIN
docs/images/app-connections/aws/access-key-create-policy.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 509 KiB |
BIN
docs/images/app-connections/aws/assume-role-create-policy.png
Normal file
BIN
docs/images/app-connections/aws/assume-role-create-policy.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 522 KiB |
Binary file not shown.
After ![]() (image error) Size: 968 KiB |
Binary file not shown.
After ![]() (image error) Size: 615 KiB |
Binary file not shown.
After ![]() (image error) Size: 610 KiB |
Binary file not shown.
After ![]() (image error) Size: 659 KiB |
Binary file not shown.
After ![]() (image error) Size: 646 KiB |
Binary file not shown.
After ![]() (image error) Size: 591 KiB |
Binary file not shown.
After ![]() (image error) Size: 571 KiB |
BIN
docs/images/secret-syncs/general/secret-sync-tab.png
Normal file
BIN
docs/images/secret-syncs/general/secret-sync-tab.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 950 KiB |
BIN
docs/images/secret-syncs/github/github-created.png
Normal file
BIN
docs/images/secret-syncs/github/github-created.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 996 KiB |
BIN
docs/images/secret-syncs/github/github-destination.png
Normal file
BIN
docs/images/secret-syncs/github/github-destination.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 623 KiB |
BIN
docs/images/secret-syncs/github/github-details.png
Normal file
BIN
docs/images/secret-syncs/github/github-details.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 617 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user