feature secret rotations v2

This commit is contained in:
Scott Wilson
2025-03-31 20:34:48 -07:00
parent da094383b8
commit 766c1242fd
275 changed files with 9692 additions and 783 deletions

View File

@@ -11,6 +11,7 @@ export const mockQueue = (): TQueueServiceFactory => {
job[name] = jobData;
},
queuePg: async () => {},
schedulePg: async () => {},
initialize: async () => {},
shutdown: async () => undefined,
stopRepeatableJob: async () => true,

View File

@@ -33,6 +33,7 @@ import { TScimServiceFactory } from "@app/ee/services/scim/scim-service";
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
@@ -237,6 +238,7 @@ declare module "fastify" {
kmip: TKmipServiceFactory;
kmipOperation: TKmipOperationServiceFactory;
gateway: TGatewayServiceFactory;
secretRotationV2: TSecretRotationV2ServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@@ -17,6 +17,9 @@ import {
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
TAppConnections,
TAppConnectionsInsert,
TAppConnectionsUpdate,
TAuditLogs,
TAuditLogsInsert,
TAuditLogStreams,
@@ -65,6 +68,9 @@ import {
TDynamicSecrets,
TDynamicSecretsInsert,
TDynamicSecretsUpdate,
TExternalGroupOrgRoleMappings,
TExternalGroupOrgRoleMappingsInsert,
TExternalGroupOrgRoleMappingsUpdate,
TExternalKms,
TExternalKmsInsert,
TExternalKmsUpdate,
@@ -299,6 +305,12 @@ import {
TSecretRotations,
TSecretRotationsInsert,
TSecretRotationsUpdate,
TSecretRotationsV2,
TSecretRotationsV2Insert,
TSecretRotationsV2Update,
TSecretRotationV2SecretMappings,
TSecretRotationV2SecretMappingsInsert,
TSecretRotationV2SecretMappingsUpdate,
TSecrets,
TSecretScanningGitRisks,
TSecretScanningGitRisksInsert,
@@ -320,15 +332,27 @@ import {
TSecretSnapshotsInsert,
TSecretSnapshotsUpdate,
TSecretsUpdate,
TSecretsV2,
TSecretsV2Insert,
TSecretsV2Update,
TSecretSyncs,
TSecretSyncsInsert,
TSecretSyncsUpdate,
TSecretTagJunction,
TSecretTagJunctionInsert,
TSecretTagJunctionUpdate,
TSecretTags,
TSecretTagsInsert,
TSecretTagsUpdate,
TSecretV2TagJunction,
TSecretV2TagJunctionInsert,
TSecretV2TagJunctionUpdate,
TSecretVersions,
TSecretVersionsInsert,
TSecretVersionsUpdate,
TSecretVersionsV2,
TSecretVersionsV2Insert,
TSecretVersionsV2Update,
TSecretVersionTagJunction,
TSecretVersionTagJunctionInsert,
TSecretVersionTagJunctionUpdate,
@@ -387,24 +411,6 @@ import {
TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate
} from "@app/db/schemas";
import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections";
import {
TExternalGroupOrgRoleMappings,
TExternalGroupOrgRoleMappingsInsert,
TExternalGroupOrgRoleMappingsUpdate
} from "@app/db/schemas/external-group-org-role-mappings";
import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs";
import {
TSecretV2TagJunction,
TSecretV2TagJunctionInsert,
TSecretV2TagJunctionUpdate
} from "@app/db/schemas/secret-v2-tag-junction";
import {
TSecretVersionsV2,
TSecretVersionsV2Insert,
TSecretVersionsV2Update
} from "@app/db/schemas/secret-versions-v2";
import { TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas/secrets-v2";
declare module "knex" {
namespace Knex {
@@ -950,5 +956,15 @@ declare module "knex/types/tables" {
TOrgGatewayConfigInsert,
TOrgGatewayConfigUpdate
>;
[TableName.SecretRotationV2]: KnexOriginal.CompositeTableType<
TSecretRotationsV2,
TSecretRotationsV2Insert,
TSecretRotationsV2Update
>;
[TableName.SecretRotationV2SecretMapping]: KnexOriginal.CompositeTableType<
TSecretRotationV2SecretMappings,
TSecretRotationV2SecretMappingsInsert,
TSecretRotationV2SecretMappingsUpdate
>;
}
}

View File

@@ -1,4 +1,5 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials"))) {
await knex.schema.alterTable(TableName.AppConnection, (t) => {
t.boolean("isPlatformManagedCredentials").defaultTo(false);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials")) {
await knex.schema.alterTable(TableName.AppConnection, (t) => {
t.dropColumn("isPlatformManagedCredentials");
});
}
}

View File

@@ -0,0 +1,57 @@
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.SecretRotationV2))) {
await knex.schema.createTable(TableName.SecretRotationV2, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name", 32).notNullable();
t.string("description");
t.string("type").notNullable();
t.jsonb("parameters").notNullable();
t.jsonb("secretsMapping").notNullable();
t.binary("encryptedGeneratedCredentials").notNullable();
t.boolean("isAutoRotationEnabled").notNullable().defaultTo(true);
t.integer("activeIndex").notNullable().defaultTo(0);
t.uuid("folderId").notNullable();
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.uuid("connectionId").notNullable();
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
t.timestamps(true, true, true);
t.integer("rotationInterval").notNullable();
t.jsonb("rotateAtUtc").notNullable(); // { hours: number; minutes: number }
t.string("rotationStatus").notNullable();
t.datetime("lastRotationAttemptedAt").notNullable();
t.datetime("lastRotatedAt").notNullable();
t.binary("encryptedLastRotationMessage"); // we encrypt this because it may contain sensitive info (SQL errors showing credentials)
t.string("lastRotationJobId");
t.datetime("nextRotationAt");
});
await createOnUpdateTrigger(knex, TableName.SecretRotationV2);
await knex.schema.alterTable(TableName.SecretRotationV2, (t) => {
t.unique(["folderId", "name"]);
});
}
if (!(await knex.schema.hasTable(TableName.SecretRotationV2SecretMapping))) {
await knex.schema.createTable(TableName.SecretRotationV2SecretMapping, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("secretId").notNullable();
// scott: this is deferred to block secret deletion but not prevent folder/environment/project deletion
// ie, if rotation is being deleted as well we permit it, otherwise throw
t.foreign("secretId").references("id").inTable(TableName.SecretV2).deferrable("deferred");
t.uuid("rotationId").notNullable();
t.foreign("rotationId").references("id").inTable(TableName.SecretRotationV2).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretRotationV2SecretMapping);
await knex.schema.dropTableIfExists(TableName.SecretRotationV2);
await dropOnUpdateTrigger(knex, TableName.SecretRotationV2);
}

View File

@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
const INDEX_NAME = "idx_unique_secret_v2_key";
export async function up(knex: Knex): Promise<void> {
const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key");
const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId");
const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type");
if (hasKeyCol && hasFolderIdCol && hasTypeCol) {
await knex.raw(`
CREATE UNIQUE INDEX ${INDEX_NAME}
ON ${TableName.SecretV2} ("key", "folderId")
WHERE type = 'shared'
`);
}
}
export async function down(knex: Knex): Promise<void> {
const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key");
const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId");
const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type");
if (hasKeyCol && hasFolderIdCol && hasTypeCol) {
await knex.raw(`DROP INDEX IF EXISTS ${INDEX_NAME}`);
}
}

View File

@@ -19,7 +19,8 @@ export const AppConnectionsSchema = z.object({
version: z.number().default(1),
orgId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
isPlatformManagedCredentials: z.boolean().default(false).nullable().optional()
});
export type TAppConnections = z.infer<typeof AppConnectionsSchema>;

View File

@@ -3,6 +3,7 @@ export * from "./access-approval-policies-approvers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
export * from "./api-keys";
export * from "./app-connections";
export * from "./audit-log-streams";
export * from "./audit-logs";
export * from "./auth-token-sessions";
@@ -19,6 +20,7 @@ export * from "./certificate-templates";
export * from "./certificates";
export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets";
export * from "./external-group-org-role-mappings";
export * from "./external-kms";
export * from "./gateways";
export * from "./git-app-install-sessions";
@@ -97,13 +99,16 @@ export * from "./secret-references";
export * from "./secret-references-v2";
export * from "./secret-rotation-output-v2";
export * from "./secret-rotation-outputs";
export * from "./secret-rotation-v2-secret-mappings";
export * from "./secret-rotations";
export * from "./secret-rotations-v2";
export * from "./secret-scanning-git-risks";
export * from "./secret-sharing";
export * from "./secret-snapshot-folders";
export * from "./secret-snapshot-secrets";
export * from "./secret-snapshot-secrets-v2";
export * from "./secret-snapshots";
export * from "./secret-syncs";
export * from "./secret-tag-junction";
export * from "./secret-tags";
export * from "./secret-v2-tag-junction";

View File

@@ -140,7 +140,9 @@ export enum TableName {
KmipClient = "kmip_clients",
KmipOrgConfig = "kmip_org_configs",
KmipOrgServerCertificates = "kmip_org_server_certificates",
KmipClientCertificates = "kmip_client_certificates"
KmipClientCertificates = "kmip_client_certificates",
SecretRotationV2 = "secret_rotations_v2",
SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";

View File

@@ -0,0 +1,23 @@
// 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 SecretRotationV2SecretMappingsSchema = z.object({
id: z.string().uuid(),
secretId: z.string().uuid(),
rotationId: z.string().uuid()
});
export type TSecretRotationV2SecretMappings = z.infer<typeof SecretRotationV2SecretMappingsSchema>;
export type TSecretRotationV2SecretMappingsInsert = Omit<
z.input<typeof SecretRotationV2SecretMappingsSchema>,
TImmutableDBKeys
>;
export type TSecretRotationV2SecretMappingsUpdate = Partial<
Omit<z.input<typeof SecretRotationV2SecretMappingsSchema>, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,38 @@
// 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 { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const SecretRotationsV2Schema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable().optional(),
type: z.string(),
parameters: z.unknown(),
secretsMapping: z.unknown(),
encryptedGeneratedCredentials: zodBuffer,
isAutoRotationEnabled: z.boolean().default(true),
activeIndex: z.number().default(0),
folderId: z.string().uuid(),
connectionId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
rotationInterval: z.number(),
rotateAtUtc: z.unknown(),
rotationStatus: z.string(),
lastRotationAttemptedAt: z.date(),
lastRotatedAt: z.date(),
encryptedLastRotationMessage: zodBuffer.nullable().optional(),
lastRotationJobId: z.string().nullable().optional(),
nextRotationAt: z.date().nullable().optional()
});
export type TSecretRotationsV2 = z.infer<typeof SecretRotationsV2Schema>;
export type TSecretRotationsV2Insert = Omit<z.input<typeof SecretRotationsV2Schema>, TImmutableDBKeys>;
export type TSecretRotationsV2Update = Partial<Omit<z.input<typeof SecretRotationsV2Schema>, TImmutableDBKeys>>;

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { SecretRotationOutputsSchema, SecretRotationsSchema } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -40,16 +41,10 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) =
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretRotation = await server.services.secretRotation.createRotation({
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
...req.body,
projectId: req.body.workspaceId
handler: async () => {
throw new BadRequestError({
message: `This version of Secret Rotations has been deprecated. Please see docs for new version.`
});
return { secretRotation };
}
});

View File

@@ -1,3 +1,8 @@
import {
registerSecretRotationV2Router,
SECRET_ROTATION_REGISTER_ROUTER_MAP
} from "@app/ee/routes/v2/secret-rotation-v2-routers";
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
import { registerProjectRoleRouter } from "./project-role-router";
@@ -13,4 +18,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerIdentityProjectAdditionalPrivilegeRouter, {
prefix: "/identity-project-additional-privilege"
});
await server.register(
async (secretRotationV2Router) => {
// register generic secret rotation endpoints
await secretRotationV2Router.register(registerSecretRotationV2Router);
// register service specific secret rotation endpoints (secret-rotations/postgres-credentials, etc.)
for await (const [type, router] of Object.entries(SECRET_ROTATION_REGISTER_ROUTER_MAP)) {
await secretRotationV2Router.register(router, { prefix: `/${type}` });
}
},
{ prefix: "/secret-rotations" }
);
};

View File

@@ -0,0 +1,14 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router";
import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router";
export * from "./secret-rotation-v2-router";
export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record<
SecretRotation,
(server: FastifyZodProvider) => Promise<void>
> = {
[SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter,
[SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter
};

View File

@@ -0,0 +1,19 @@
import {
CreateMsSqlCredentialsRotationSchema,
MsSqlCredentialsRotationSchema,
UpdateMsSqlCredentialsRotationSchema
} from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
export const registerMsSqlCredentialsRotationRouter = async (server: FastifyZodProvider) =>
registerSecretRotationEndpoints({
type: SecretRotation.MsSqlCredentials,
server,
responseSchema: MsSqlCredentialsRotationSchema,
createSchema: CreateMsSqlCredentialsRotationSchema,
updateSchema: UpdateMsSqlCredentialsRotationSchema,
generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema
});

View File

@@ -0,0 +1,19 @@
import {
CreatePostgresCredentialsRotationSchema,
PostgresCredentialsRotationSchema,
UpdatePostgresCredentialsRotationSchema
} from "@app/ee/services/secret-rotation-v2/postgres-credentials";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
export const registerPostgresCredentialsRotationRouter = async (server: FastifyZodProvider) =>
registerSecretRotationEndpoints({
type: SecretRotation.PostgresCredentials,
server,
responseSchema: PostgresCredentialsRotationSchema,
createSchema: CreatePostgresCredentialsRotationSchema,
updateSchema: UpdatePostgresCredentialsRotationSchema,
generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema
});

View File

@@ -0,0 +1,429 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
import {
TRotateAtUtc,
TSecretRotationV2,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2Input
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { SecretRotations } 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";
export const registerSecretRotationEndpoints = <
T extends TSecretRotationV2,
I extends TSecretRotationV2Input,
C extends TSecretRotationV2GeneratedCredentials
>({
server,
type,
createSchema,
updateSchema,
responseSchema,
generatedCredentialsSchema
}: {
type: SecretRotation;
server: FastifyZodProvider;
createSchema: z.ZodType<{
name: string;
environment: string;
secretPath: string;
projectId: string;
connectionId: string;
parameters: I["parameters"];
secretsMapping: I["secretsMapping"];
description?: string | null;
isAutoRotationEnabled?: boolean;
rotationInterval: number;
rotateAtUtc?: TRotateAtUtc;
}>;
updateSchema: z.ZodType<{
connectionId?: string;
name?: string;
environment?: string;
secretPath?: string;
parameters?: I["parameters"];
secretsMapping?: I["secretsMapping"];
description?: string | null;
isAutoRotationEnabled?: boolean;
rotationInterval?: number;
rotateAtUtc?: TRotateAtUtc;
}>;
responseSchema: z.ZodTypeAny;
generatedCredentialsSchema: z.ZodTypeAny;
}) => {
const rotationType = SECRET_ROTATION_NAME_MAP[type];
server.route({
method: "GET",
url: `/`,
config: {
rateLimit: readLimit
},
schema: {
description: `List the ${rotationType} Rotations for the specified project.`,
querystring: z.object({
projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST(type).projectId)
}),
response: {
200: z.object({ secretRotations: responseSchema.array() })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const {
query: { projectId }
} = req;
const secretRotations = (await server.services.secretRotationV2.listSecretRotationsByProjectId(
{ projectId, type },
req.permission
)) as T[];
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_ROTATIONS,
metadata: {
type,
count: secretRotations.length,
rotationIds: secretRotations.map((rotation) => rotation.id)
}
}
});
return { secretRotations };
}
});
server.route({
method: "GET",
url: "/:rotationId",
config: {
rateLimit: readLimit
},
schema: {
description: `Get the specified ${rotationType} Rotation by ID.`,
params: z.object({
rotationId: z.string().uuid().describe(SecretRotations.GET_BY_ID(type).rotationId)
}),
response: {
200: z.object({ secretRotation: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { rotationId } = req.params;
const secretRotation = (await server.services.secretRotationV2.findSecretRotationById(
{ rotationId, type },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretRotation.projectId,
event: {
type: EventType.GET_SECRET_ROTATION,
metadata: {
rotationId,
type,
secretPath: secretRotation.folder.path,
environment: secretRotation.environment.slug
}
}
});
return { secretRotation };
}
});
server.route({
method: "GET",
url: `/rotation-name/:rotationName`,
config: {
rateLimit: readLimit
},
schema: {
description: `Get the specified ${rotationType} Rotation by name and project ID.`,
params: z.object({
rotationName: z
.string()
.trim()
.min(1, "Rotation name required")
.describe(SecretRotations.GET_BY_NAME(type).rotationName)
}),
querystring: z.object({
projectId: z
.string()
.trim()
.min(1, "Project ID required")
.describe(SecretRotations.GET_BY_NAME(type).projectId),
secretPath: z
.string()
.trim()
.min(1, "Secret path required")
.describe(SecretRotations.GET_BY_NAME(type).secretPath),
environment: z
.string()
.trim()
.min(1, "Environment required")
.describe(SecretRotations.GET_BY_NAME(type).environment)
}),
response: {
200: z.object({ secretRotation: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { rotationName } = req.params;
const { projectId, secretPath, environment } = req.query;
const secretRotation = (await server.services.secretRotationV2.findSecretRotationByName(
{ rotationName, projectId, type, secretPath, environment },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_ROTATION,
metadata: {
rotationId: secretRotation.id,
type,
secretPath,
environment
}
}
});
return { secretRotation };
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: `Create ${
startsWithVowel(rotationType) ? "an" : "a"
} ${rotationType} Rotation for the specified project.`,
body: createSchema,
response: {
200: z.object({ secretRotation: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const secretRotation = (await server.services.secretRotationV2.createSecretRotation(
{ ...req.body, type },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretRotation.projectId,
event: {
type: EventType.CREATE_SECRET_ROTATION,
metadata: {
rotationId: secretRotation.id,
type,
...req.body
}
}
});
return { secretRotation };
}
});
server.route({
method: "PATCH",
url: "/:rotationId",
config: {
rateLimit: writeLimit
},
schema: {
description: `Update the specified ${rotationType} Rotation.`,
params: z.object({
rotationId: z.string().uuid().describe(SecretRotations.UPDATE(type).rotationId)
}),
body: updateSchema,
response: {
200: z.object({ secretRotation: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { rotationId } = req.params;
const secretRotation = (await server.services.secretRotationV2.updateSecretRotation(
{ ...req.body, rotationId, type },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretRotation.projectId,
event: {
type: EventType.UPDATE_SECRET_ROTATION,
metadata: {
rotationId,
type,
...req.body
}
}
});
return { secretRotation };
}
});
server.route({
method: "DELETE",
url: `/:rotationId`,
config: {
rateLimit: writeLimit
},
schema: {
description: `Delete the specified ${rotationType} Rotation.`,
params: z.object({
rotationId: z.string().uuid().describe(SecretRotations.DELETE(type).rotationId)
}),
querystring: z.object({
deleteSecrets: z
.enum(["true", "false"])
.transform((value) => value === "true")
.describe(SecretRotations.DELETE(type).deleteSecrets),
revokeGeneratedCredentials: z
.enum(["true", "false"])
.transform((value) => value === "true")
.describe(SecretRotations.DELETE(type).revokeGeneratedCredentials)
}),
response: {
200: z.object({ secretRotation: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { rotationId } = req.params;
const { deleteSecrets, revokeGeneratedCredentials } = req.query;
const secretRotation = (await server.services.secretRotationV2.deleteSecretRotation(
{ type, rotationId, deleteSecrets, revokeGeneratedCredentials },
req.permission
)) as T;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: secretRotation.projectId,
event: {
type: EventType.DELETE_SECRET_ROTATION,
metadata: {
type,
rotationId,
deleteSecrets,
revokeGeneratedCredentials
}
}
});
return { secretRotation };
}
});
server.route({
method: "GET",
url: "/:rotationId/generated-credentials",
config: {
rateLimit: readLimit
},
schema: {
description: `Get the generated credentials for the specified ${rotationType} Rotation.`,
params: z.object({
rotationId: z.string().uuid().describe(SecretRotations.GET_GENERATED_CREDENTIALS_BY_ID(type).rotationId)
}),
response: {
200: z.object({
generatedCredentials: generatedCredentialsSchema,
activeIndex: z.number(),
rotationId: z.string().uuid(),
type: z.literal(type)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { rotationId } = req.params;
const {
generatedCredentials,
secretRotation: { activeIndex, projectId, folder, environment }
} = await server.services.secretRotationV2.findSecretRotationGeneratedCredentialsById(
{
rotationId,
type
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS,
metadata: {
type,
rotationId,
secretPath: folder.path,
environment: environment.slug
}
}
});
return { generatedCredentials: generatedCredentials as C, activeIndex, rotationId, type };
}
});
server.route({
method: "POST",
url: "/:rotationId/rotate-secrets",
config: {
rateLimit: writeLimit
},
schema: {
description: `Rotate the generated credentials for the specified ${rotationType} Rotation.`,
params: z.object({
rotationId: z.string().uuid().describe(SecretRotations.ROTATE(type).rotationId)
}),
response: {
200: z.object({ secretRotation: responseSchema })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { rotationId } = req.params;
const secretRotation = (await server.services.secretRotationV2.rotateSecretRotation(
{
rotationId,
type,
auditLogInfo: req.auditLogInfo
},
req.permission
)) as T;
return { secretRotation };
}
});
};

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { SecretRotations } 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";
const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [
PostgresCredentialsRotationListItemSchema,
MsSqlCredentialsRotationListItemSchema
]);
export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/options",
config: {
rateLimit: readLimit
},
schema: {
description: "List the available Secret Rotation Options.",
response: {
200: z.object({
secretRotationOptions: SecretRotationV2OptionsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: () => {
const secretRotationOptions = server.services.secretRotationV2.listSecretRotationOptions();
return { secretRotationOptions };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List all the Secret Rotations for the specified project.",
querystring: z.object({
projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST().projectId)
}),
response: {
200: z.object({ secretRotations: SecretRotationV2Schema.array() })
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const {
query: { projectId },
permission
} = req;
const secretRotations = await server.services.secretRotationV2.listSecretRotationsByProjectId(
{ projectId },
permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.GET_SECRET_ROTATIONS,
metadata: {
rotationIds: secretRotations.map((sync) => sync.id),
count: secretRotations.length
}
}
});
return { secretRotations };
}
});
};

View File

@@ -2,6 +2,13 @@ import {
TCreateProjectTemplateDTO,
TUpdateProjectTemplateDTO
} from "@app/ee/services/project-template/project-template-types";
import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
TCreateSecretRotationV2DTO,
TDeleteSecretRotationV2DTO,
TSecretRotationV2Raw,
TUpdateSecretRotationV2DTO
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
import { SymmetricEncryption } from "@app/lib/crypto/cipher";
@@ -54,6 +61,8 @@ export type TCreateAuditLogDTO = {
projectId?: string;
} & BaseAuthData;
export type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">;
interface BaseAuthData {
ipAddress?: string;
userAgent?: string;
@@ -283,7 +292,15 @@ export enum EventType {
KMIP_OPERATION_ACTIVATE = "kmip-operation-activate",
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
KMIP_OPERATION_LOCATE = "kmip-operation-locate",
KMIP_OPERATION_REGISTER = "kmip-operation-register"
KMIP_OPERATION_REGISTER = "kmip-operation-register",
GET_SECRET_ROTATIONS = "get-secret-rotations",
GET_SECRET_ROTATION = "get-secret-rotation",
GET_SECRET_ROTATION_GENERATED_CREDENTIALS = "get-secret-rotation-generated-credentials",
CREATE_SECRET_ROTATION = "create-secret-rotation",
UPDATE_SECRET_ROTATION = "update-secret-rotation",
DELETE_SECRET_ROTATION = "delete-secret-rotation",
SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets"
}
interface UserActorMetadata {
@@ -2290,6 +2307,63 @@ interface RegisterKmipServerEvent {
};
}
interface GetSecretRotationsEvent {
type: EventType.GET_SECRET_ROTATIONS;
metadata: {
type?: SecretRotation;
count: number;
rotationIds: string[];
secretPath?: string;
environment?: string;
};
}
interface GetSecretRotationEvent {
type: EventType.GET_SECRET_ROTATION;
metadata: {
type: SecretRotation;
rotationId: string;
secretPath: string;
environment: string;
};
}
interface GetSecretRotationCredentialsEvent {
type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS;
metadata: {
type: SecretRotation;
rotationId: string;
secretPath: string;
environment: string;
};
}
interface CreateSecretRotationEvent {
type: EventType.CREATE_SECRET_ROTATION;
metadata: Omit<TCreateSecretRotationV2DTO, "projectId"> & { rotationId: string };
}
interface UpdateSecretRotationEvent {
type: EventType.UPDATE_SECRET_ROTATION;
metadata: TUpdateSecretRotationV2DTO;
}
interface DeleteSecretRotationEvent {
type: EventType.DELETE_SECRET_ROTATION;
metadata: TDeleteSecretRotationV2DTO;
}
interface RotateSecretRotationEvent {
type: EventType.SECRET_ROTATION_ROTATE_SECRETS;
metadata: Pick<TSecretRotationV2Raw, "parameters" | "secretsMapping" | "type" | "connectionId" | "folderId"> & {
status: SecretRotationStatus;
rotationId: string;
jobId?: string | undefined;
occurredAt: Date;
message?: string | null | undefined;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -2500,4 +2574,11 @@ export type Event =
| KmipOperationLocateEvent
| KmipOperationRegisterEvent
| CreateSecretRequestEvent
| SecretApprovalRequestReview;
| SecretApprovalRequestReview
| GetSecretRotationsEvent
| GetSecretRotationEvent
| GetSecretRotationCredentialsEvent
| CreateSecretRotationEvent
| UpdateSecretRotationEvent
| DeleteSecretRotationEvent
| RotateSecretRotationEvent;

View File

@@ -8,7 +8,8 @@ import { getDbConnectionHost } from "@app/lib/knex";
export const verifyHostInputValidity = async (host: string, isGateway = false) => {
const appCfg = getConfig();
// if (appCfg.NODE_ENV === "development") return ["host.docker.internal"]; // incase you want to remove this check in dev
if (appCfg.isDevelopmentMode) return [host];
const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat(
(appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)),

View File

@@ -39,7 +39,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
trial_end: null,
has_used_trial: true,
secretApproval: false,
secretRotation: true,
secretRotation: false,
caCrl: false,
instanceUserManagement: false,
externalKms: false,

View File

@@ -56,7 +56,7 @@ export type TFeatureSet = {
trial_end: null;
has_used_trial: true;
secretApproval: false;
secretRotation: true;
secretRotation: false;
caCrl: false;
instanceUserManagement: false;
externalKms: false;

View File

@@ -77,6 +77,15 @@ export enum ProjectPermissionSecretSyncActions {
RemoveSecrets = "remove-secrets"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
Create = "create",
Edit = "edit",
Delete = "delete",
RotateSecrets = "rotate-secrets"
}
export enum ProjectPermissionKmipActions {
CreateClients = "create-clients",
UpdateClients = "update-clients",
@@ -142,6 +151,11 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
export type SecretRotationsSubjectFields = {
environment: string;
secretPath: string;
};
export type IdentityManagementSubjectFields = {
identityId: string;
};
@@ -184,7 +198,13 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [
ProjectPermissionSecretRotationActions,
(
| ProjectPermissionSub.SecretRotation
| (ForcedSubject<ProjectPermissionSub.SecretRotation> & SecretRotationsSubjectFields)
)
]
| [
ProjectPermissionIdentityActions,
ProjectPermissionSub.Identity | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
@@ -300,12 +320,6 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretRollback).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read, ProjectPermissionActions.Create]).describe(
@@ -487,6 +501,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
...GeneralPermissionSchema
]);
@@ -541,6 +561,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretRotationActions).describe(
"Describe what action an entity can take."
),
conditions: SecretConditionV1Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema
]);
@@ -554,7 +584,6 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.SecretImports,
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Role,
ProjectPermissionSub.Integrations,
ProjectPermissionSub.Webhooks,
@@ -678,6 +707,18 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.Kmip
);
can(
[
ProjectPermissionSecretRotationActions.Create,
ProjectPermissionSecretRotationActions.Edit,
ProjectPermissionSecretRotationActions.Delete,
ProjectPermissionSecretRotationActions.Read,
ProjectPermissionSecretRotationActions.ReadGeneratedCredentials,
ProjectPermissionSecretRotationActions.RotateSecrets
],
ProjectPermissionSub.SecretRotation
);
return rules;
};
@@ -727,7 +768,7 @@ const buildMemberPermissionRules = () => {
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation);
can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation);
can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback);
@@ -873,7 +914,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation);
can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Role);

View File

@@ -0,0 +1,3 @@
export * from "./mssql-credentials-rotation-constants";
export * from "./mssql-credentials-rotation-schemas";
export * from "./mssql-credentials-rotation-types";

View File

@@ -0,0 +1,16 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const MSSQL_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
name: "Microsoft SQL Server Credentials",
type: SecretRotation.MsSqlCredentials,
connection: AppConnection.MsSql,
template: {
createUserStatement: `CREATE LOGIN [my_mssql_user] WITH PASSWORD = 'my_temporary_password'; CREATE USER [my_mssql_user] FOR LOGIN [my_mssql_user]; GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [my_mssql_user];`,
secretsMapping: {
username: "MSSQL_DB_USERNAME",
password: "MSSQL_DB_PASSWORD"
}
}
};

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
BaseCreateSecretRotationSchema,
BaseSecretRotationSchema,
BaseUpdateSecretRotationSchema
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import {
SqlCredentialsRotationParametersSchema,
SqlCredentialsRotationSecretsMappingSchema,
SqlCredentialsRotationTemplateSchema
} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const MsSqlCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MsSqlCredentials).extend({
type: z.literal(SecretRotation.MsSqlCredentials),
parameters: SqlCredentialsRotationParametersSchema,
secretsMapping: SqlCredentialsRotationSecretsMappingSchema
});
export const CreateMsSqlCredentialsRotationSchema = BaseCreateSecretRotationSchema(
SecretRotation.MsSqlCredentials
).extend({
parameters: SqlCredentialsRotationParametersSchema,
secretsMapping: SqlCredentialsRotationSecretsMappingSchema
});
export const UpdateMsSqlCredentialsRotationSchema = BaseUpdateSecretRotationSchema(
SecretRotation.MsSqlCredentials
).extend({
parameters: SqlCredentialsRotationParametersSchema.optional(),
secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional()
});
export const MsSqlCredentialsRotationListItemSchema = z.object({
name: z.literal("Microsoft SQL Server Credentials"),
connection: z.literal(AppConnection.MsSql),
type: z.literal(SecretRotation.MsSqlCredentials),
template: SqlCredentialsRotationTemplateSchema
});

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
import { TMsSqlConnection } from "@app/services/app-connection/mssql";
import {
CreateMsSqlCredentialsRotationSchema,
MsSqlCredentialsRotationListItemSchema,
MsSqlCredentialsRotationSchema
} from "./mssql-credentials-rotation-schemas";
export type TMsSqlCredentialsRotation = z.infer<typeof MsSqlCredentialsRotationSchema>;
export type TMsSqlCredentialsRotationInput = z.infer<typeof CreateMsSqlCredentialsRotationSchema>;
export type TMsSqlCredentialsRotationListItem = z.infer<typeof MsSqlCredentialsRotationListItemSchema>;
export type TMsSqlCredentialsRotationWithConnection = TMsSqlCredentialsRotation & {
connection: TMsSqlConnection;
};

View File

@@ -0,0 +1,3 @@
export * from "./postgres-credentials-rotation-constants";
export * from "./postgres-credentials-rotation-schemas";
export * from "./postgres-credentials-rotation-types";

View File

@@ -0,0 +1,16 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
name: "PostgreSQL Credentials",
type: SecretRotation.PostgresCredentials,
connection: AppConnection.Postgres,
template: {
createUserStatement: `CREATE USER "my_pg_user" WITH ENCRYPTED PASSWORD 'temporary_password'; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "my_pg_user";`,
secretsMapping: {
username: "POSTGRES_DB_USERNAME",
password: "POSTGRES_DB_PASSWORD"
}
}
};

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
BaseCreateSecretRotationSchema,
BaseSecretRotationSchema,
BaseUpdateSecretRotationSchema
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import {
SqlCredentialsRotationParametersSchema,
SqlCredentialsRotationSecretsMappingSchema,
SqlCredentialsRotationTemplateSchema
} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const PostgresCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.PostgresCredentials).extend({
type: z.literal(SecretRotation.PostgresCredentials),
parameters: SqlCredentialsRotationParametersSchema,
secretsMapping: SqlCredentialsRotationSecretsMappingSchema
});
export const CreatePostgresCredentialsRotationSchema = BaseCreateSecretRotationSchema(
SecretRotation.PostgresCredentials
).extend({
parameters: SqlCredentialsRotationParametersSchema,
secretsMapping: SqlCredentialsRotationSecretsMappingSchema
});
export const UpdatePostgresCredentialsRotationSchema = BaseUpdateSecretRotationSchema(
SecretRotation.PostgresCredentials
).extend({
parameters: SqlCredentialsRotationParametersSchema.optional(),
secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional()
});
export const PostgresCredentialsRotationListItemSchema = z.object({
name: z.literal("PostgreSQL Credentials"),
connection: z.literal(AppConnection.Postgres),
type: z.literal(SecretRotation.PostgresCredentials),
template: SqlCredentialsRotationTemplateSchema
});

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
import { TPostgresConnection } from "@app/services/app-connection/postgres";
import {
CreatePostgresCredentialsRotationSchema,
PostgresCredentialsRotationListItemSchema,
PostgresCredentialsRotationSchema
} from "./postgres-credentials-rotation-schemas";
export type TPostgresCredentialsRotation = z.infer<typeof PostgresCredentialsRotationSchema>;
export type TPostgresCredentialsRotationInput = z.infer<typeof CreatePostgresCredentialsRotationSchema>;
export type TPostgresCredentialsRotationListItem = z.infer<typeof PostgresCredentialsRotationListItemSchema>;
export type TPostgresCredentialsRotationWithConnection = TPostgresCredentialsRotation & {
connection: TPostgresConnection;
};

View File

@@ -0,0 +1,465 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TSecretRotationsV2 } from "@app/db/schemas/secret-rotations-v2";
import { DatabaseError } from "@app/lib/errors";
import {
buildFindFilter,
ormify,
prependTableNameToFindFilter,
selectAllTableCols,
sqlNestRelationships,
TFindOpt
} from "@app/lib/knex";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
export type TSecretRotationV2DALFactory = ReturnType<typeof secretRotationV2DALFactory>;
type TSecretRotationFindFilter = Parameters<typeof buildFindFilter<TSecretRotationsV2>>[0];
type TSecretRotationFindOptions = TFindOpt<TSecretRotationsV2, true, "name">;
const baseSecretRotationV2Query = ({
filter = {},
options,
db,
tx
}: {
db: TDbClient;
filter?: { projectId?: string } & TSecretRotationFindFilter;
options?: TSecretRotationFindOptions;
tx?: Knex;
}) => {
const { projectId, ...filters } = filter;
const query = (tx || db.replicaNode())(TableName.SecretRotationV2)
.join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(TableName.AppConnection, `${TableName.SecretRotationV2}.connectionId`, `${TableName.AppConnection}.id`)
.select(selectAllTableCols(TableName.SecretRotationV2))
.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"),
db.ref("projectId").withSchema(TableName.Environment),
// entire connection
db.ref("name").withSchema(TableName.AppConnection).as("connectionName"),
db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"),
db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"),
db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"),
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"),
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
db
.ref("isPlatformManagedCredentials")
.withSchema(TableName.AppConnection)
.as("connectionIsPlatformManagedCredentials")
);
if (filter) {
/* eslint-disable @typescript-eslint/no-misused-promises */
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filters)));
}
if (projectId) {
void query.where(`${TableName.Environment}.projectId`, projectId);
}
if (options) {
const { offset, limit, sort, count, countDistinct } = options;
if (countDistinct) {
void query.countDistinct(countDistinct);
} else if (count) {
void query.select(db.raw("COUNT(*) OVER() AS count"));
void query.select("*");
}
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
}
return query;
};
const expandSecretRotation = <T extends Awaited<ReturnType<typeof baseSecretRotationV2Query>>[number]>(
secretRotation: T,
folder: Awaited<ReturnType<TSecretFolderDALFactory["findSecretPathByFolderIds"]>>[number]
) => {
const {
envId,
envName,
envSlug,
connectionApp,
connectionName,
connectionId,
connectionOrgId,
connectionEncryptedCredentials,
connectionMethod,
connectionDescription,
connectionCreatedAt,
connectionUpdatedAt,
connectionVersion,
connectionIsPlatformManagedCredentials,
...el
} = secretRotation;
return {
...el,
connectionId,
environment: { id: envId, name: envName, slug: envSlug },
connection: {
app: connectionApp,
id: connectionId,
name: connectionName,
orgId: connectionOrgId,
encryptedCredentials: connectionEncryptedCredentials,
method: connectionMethod,
description: connectionDescription,
createdAt: connectionCreatedAt,
updatedAt: connectionUpdatedAt,
version: connectionVersion,
isPlatformManagedCredentials: connectionIsPlatformManagedCredentials
},
folder: {
id: folder!.id,
path: folder!.path
}
};
};
export const secretRotationV2DALFactory = (
db: TDbClient,
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">
) => {
const secretRotationV2Orm = ormify(db, TableName.SecretRotationV2);
const secretRotationV2SecretMappingOrm = ormify(db, TableName.SecretRotationV2SecretMapping);
const find = async (
filter: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string },
options?: TSecretRotationFindOptions,
tx?: Knex
) => {
try {
const secretRotations = await baseSecretRotationV2Query({ filter, db, tx, options });
if (!secretRotations.length) return [];
const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
filter.projectId,
secretRotations.map((rotation) => rotation.folderId),
tx
);
const folderRecord: Record<string, (typeof foldersWithPath)[number]> = {};
foldersWithPath.forEach((folder) => {
if (folder) folderRecord[folder.id] = folder;
});
return secretRotations.map((rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId]));
} catch (error) {
throw new DatabaseError({ error, name: "Find - Secret Rotation V2" });
}
};
const findWithMappedSecretsCount = async (
{
search,
projectId,
...filter
}: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string },
tx?: Knex
) => {
const query = (tx || db.replicaNode())(TableName.SecretRotationV2)
.join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(
TableName.SecretRotationV2SecretMapping,
`${TableName.SecretRotationV2SecretMapping}.rotationId`,
`${TableName.SecretRotationV2}.id`
)
.join(TableName.SecretV2, `${TableName.SecretRotationV2SecretMapping}.secretId`, `${TableName.SecretV2}.id`)
.where(`${TableName.Environment}.projectId`, projectId)
.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filter)))
.countDistinct(`${TableName.SecretRotationV2}.name`);
if (search) {
void query
.whereILike(`${TableName.SecretV2}.key`, `%${search}%`)
.orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`);
}
const result = await query;
// @ts-expect-error knex infers wrong type...
return Number(result[0]?.count ?? 0);
};
const findWithMappedSecrets = async (
{ search, ...filter }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string },
options?: TSecretRotationFindOptions,
tx?: Knex
) => {
try {
const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options })
.join(
TableName.SecretRotationV2SecretMapping,
`${TableName.SecretRotationV2SecretMapping}.rotationId`,
`${TableName.SecretRotationV2}.id`
)
.join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId`)
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.select(
db.ref("id").withSchema(TableName.SecretV2).as("secretId"),
db.ref("key").withSchema(TableName.SecretV2).as("secretKey"),
db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"),
db.ref("type").withSchema(TableName.SecretV2).as("secretType"),
db.ref("encryptedValue").withSchema(TableName.SecretV2).as("secretEncryptedValue"),
db.ref("encryptedComment").withSchema(TableName.SecretV2).as("secretEncryptedComment"),
db.ref("reminderNote").withSchema(TableName.SecretV2).as("secretReminderNote"),
db.ref("reminderRepeatDays").withSchema(TableName.SecretV2).as("secretReminderRepeatDays"),
db.ref("skipMultilineEncoding").withSchema(TableName.SecretV2).as("secretSkipMultilineEncoding"),
db.ref("metadata").withSchema(TableName.SecretV2).as("secretMetadata"),
db.ref("userId").withSchema(TableName.SecretV2).as("secretUserId"),
db.ref("folderId").withSchema(TableName.SecretV2).as("secretFolderId"),
db.ref("createdAt").withSchema(TableName.SecretV2).as("secretCreatedAt"),
db.ref("updatedAt").withSchema(TableName.SecretV2).as("secretUpdatedAt"),
db.ref("id").withSchema(TableName.SecretTag).as("tagId"),
db.ref("color").withSchema(TableName.SecretTag).as("tagColor"),
db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"),
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
);
if (search) {
void extendedQuery.where((query) => {
void query
.whereILike(`${TableName.SecretV2}.key`, `%${search}%`)
.orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`);
});
}
const secretRotations = await extendedQuery;
if (!secretRotations.length) return [];
const foldersWithPath = await folderDAL.findSecretPathByFolderIds(
filter.projectId,
secretRotations.map((rotation) => rotation.folderId),
tx
);
const folderRecord: Record<string, (typeof foldersWithPath)[number]> = {};
foldersWithPath.forEach((folder) => {
if (folder) folderRecord[folder.id] = folder;
});
return sqlNestRelationships({
data: secretRotations,
key: "id",
parentMapper: (rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId]),
childrenMapper: [
{
key: "secretId",
label: "secrets" as const,
mapper: ({
secretId,
secretKey,
secretVersion,
secretType,
secretEncryptedValue,
secretEncryptedComment,
secretReminderNote,
secretReminderRepeatDays,
secretSkipMultilineEncoding,
secretMetadata,
secretUserId,
secretFolderId,
secretCreatedAt,
secretUpdatedAt,
id
}) => ({
id: secretId,
key: secretKey,
version: secretVersion,
type: secretType,
encryptedValue: secretEncryptedValue,
encryptedComment: secretEncryptedComment,
reminderNote: secretReminderNote,
reminderRepeatDays: secretReminderRepeatDays,
skipMultilineEncoding: secretSkipMultilineEncoding,
metadata: secretMetadata,
userId: secretUserId,
folderId: secretFolderId,
createdAt: secretCreatedAt,
updatedAt: secretUpdatedAt,
rotationId: id,
isRotatedSecret: true
}),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
id,
color,
slug,
name: slug
})
},
{
key: "metadataId",
label: "secretMetadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
}
]
});
} catch (error) {
throw new DatabaseError({ error, name: "Find with Mapped Secrets - Secret Rotation V2" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const secretRotation = await baseSecretRotationV2Query({
filter: { id },
db,
tx
}).first();
if (secretRotation) {
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
secretRotation.projectId,
[secretRotation.folderId],
tx
);
return expandSecretRotation(secretRotation, folderWithPath);
}
} catch (error) {
throw new DatabaseError({ error, name: "Find by ID - Secret Rotation V2" });
}
};
const create = async (data: Parameters<(typeof secretRotationV2Orm)["create"]>[0], tx?: Knex) => {
const rotation = await secretRotationV2Orm.create(data, tx);
const secretRotation = (await baseSecretRotationV2Query({
filter: { id: rotation.id },
db,
tx
}).first())!;
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
secretRotation.projectId,
[secretRotation.folderId],
tx
);
return expandSecretRotation(secretRotation, folderWithPath);
};
const updateById = async (
rotationId: string,
data: Parameters<(typeof secretRotationV2Orm)["updateById"]>[1],
tx?: Knex
) => {
const rotation = await secretRotationV2Orm.updateById(rotationId, data, tx);
const secretRotation = (await baseSecretRotationV2Query({
filter: { id: rotation.id },
db,
tx
}).first())!;
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
secretRotation.projectId,
[secretRotation.folderId],
tx
);
return expandSecretRotation(secretRotation, folderWithPath);
};
const deleteById = async (rotationId: string, tx?: Knex) => {
const secretRotation = (await baseSecretRotationV2Query({
filter: { id: rotationId },
db,
tx
}).first())!;
await secretRotationV2Orm.deleteById(rotationId, tx);
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
secretRotation.projectId,
[secretRotation.folderId],
tx
);
return expandSecretRotation(secretRotation, folderWithPath);
};
const findOne = async (filter: Parameters<(typeof secretRotationV2Orm)["findOne"]>[0], tx?: Knex) => {
try {
const secretRotation = await baseSecretRotationV2Query({ filter, db, tx }).first();
if (secretRotation) {
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(
secretRotation.projectId,
[secretRotation.folderId],
tx
);
return expandSecretRotation(secretRotation, folderWithPath);
}
} catch (error) {
throw new DatabaseError({ error, name: "Find One - Secret Rotation V2" });
}
};
const findSecretRotationsToQueue = async (rotateBy: Date, tx?: Knex) => {
const secretRotations = await (tx || db.replicaNode())(TableName.SecretRotationV2)
.where(`${TableName.SecretRotationV2}.isAutoRotationEnabled`, true)
.whereNotNull(`${TableName.SecretRotationV2}.nextRotationAt`)
.andWhereRaw(`"nextRotationAt" <= ?`, [rotateBy])
.select(selectAllTableCols(TableName.SecretRotationV2));
return secretRotations;
};
return {
...secretRotationV2Orm,
find,
create,
findById,
updateById,
deleteById,
findOne,
insertSecretMappings: secretRotationV2SecretMappingOrm.insertMany,
findWithMappedSecrets,
findWithMappedSecretsCount,
findSecretRotationsToQueue
};
};

View File

@@ -0,0 +1,9 @@
export enum SecretRotation {
PostgresCredentials = "postgres-credentials",
MsSqlCredentials = "mssql-credentials"
}
export enum SecretRotationStatus {
Success = "success",
Failed = "failed"
}

View File

@@ -0,0 +1,222 @@
import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
import {
TSecretRotationV2,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2ListItem,
TSecretRotationV2Raw
} from "./secret-rotation-v2-types";
const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = {
[SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION,
[SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION
};
export const listSecretRotationOptions = () => {
return Object.values(SECRET_ROTATION_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
};
const getNextUTCMidnight = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => {
const now = new Date();
return new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + 1, // Add 1 day to get tomorrow
hours,
minutes,
0,
0
)
);
};
const getNextUTCMinute = ({ minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => {
const now = new Date();
return new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours(),
now.getUTCMinutes() + 1, // Add 1 minute to get the next minute
minutes, // use minutes as seconds in dev
0
)
);
};
export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rotateAtUtc"]) => {
const appCfg = getConfig();
if (appCfg.isRotationDevelopmentMode) {
return getNextUTCMinute(rotateAtUtc);
}
return getNextUTCMidnight(rotateAtUtc);
};
export const encryptSecretRotationCredentials = async ({
projectId,
generatedCredentials,
kmsService
}: {
projectId: string;
generatedCredentials: TSecretRotationV2GeneratedCredentials;
kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"];
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(generatedCredentials))
});
return encryptedCredentialsBlob;
};
export const decryptSecretRotationCredentials = async ({
projectId,
encryptedGeneratedCredentials,
kmsService
}: {
projectId: string;
encryptedGeneratedCredentials: Buffer;
kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"];
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedGeneratedCredentials
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TSecretRotationV2GeneratedCredentials;
};
export const getSecretRotationRotateSecretJobOptions = ({
id,
nextRotationAt
}: Pick<TSecretRotationV2Raw, "id" | "nextRotationAt">) => {
const appCfg = getConfig();
return {
jobId: `secret-rotation-v2-rotate-${id}`,
retryLimit: appCfg.isRotationDevelopmentMode ? 3 : 5,
retryBackoff: true,
startAfter: nextRotationAt ?? undefined
};
};
export const calculateNextRotationAt = ({
rotateAtUtc,
isAutoRotationEnabled,
rotationInterval,
rotationStatus,
isManualRotation,
...params
}: Pick<
TSecretRotationV2,
"isAutoRotationEnabled" | "lastRotatedAt" | "rotateAtUtc" | "rotationInterval" | "rotationStatus"
> & { isManualRotation: boolean }) => {
if (!isAutoRotationEnabled) return null;
if (rotationStatus === SecretRotationStatus.Failed) {
return getNextUtcRotationInterval(rotateAtUtc);
}
const lastRotatedAt = new Date(params.lastRotatedAt);
const appCfg = getConfig();
if (appCfg.isRotationDevelopmentMode) {
// treat interval as minute
const nextRotation = new Date(lastRotatedAt.getTime() + rotationInterval * 60 * 1000);
// in development mode we use rotateAtUtc.minutes as seconds
nextRotation.setUTCSeconds(rotateAtUtc.minutes);
nextRotation.setUTCMilliseconds(0);
// If creation/manual rotation seconds are after the configured seconds we pad an additional minute
// to ensure a full interval has elapsed before rotation
if (isManualRotation && lastRotatedAt.getUTCSeconds() >= rotateAtUtc.minutes) {
nextRotation.setUTCMinutes(nextRotation.getUTCMinutes() + 1);
}
return nextRotation;
}
// production mode - rotationInterval = days
const nextRotation = new Date(lastRotatedAt);
nextRotation.setUTCHours(rotateAtUtc.hours);
nextRotation.setUTCMinutes(rotateAtUtc.minutes);
nextRotation.setUTCSeconds(0);
nextRotation.setUTCMilliseconds(0);
// If creation/manual rotation was after the daily rotation time,
// we need pad an additional day to ensure full rotation interval
if (
isManualRotation &&
(lastRotatedAt.getUTCHours() > rotateAtUtc.hours ||
(lastRotatedAt.getUTCHours() === rotateAtUtc.hours && lastRotatedAt.getUTCMinutes() >= rotateAtUtc.minutes))
) {
nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval + 1);
} else {
nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval);
}
return nextRotation;
};
export const expandSecretRotation = async (
{ encryptedLastRotationMessage, ...secretRotation }: TSecretRotationV2Raw,
kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"]
) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: secretRotation.projectId
});
const lastRotationMessage = encryptedLastRotationMessage
? decryptor({
cipherTextBlob: encryptedLastRotationMessage
}).toString()
: null;
return {
...secretRotation,
lastRotationMessage
} as TSecretRotationV2;
};
const MAX_MESSAGE_LENGTH = 1024;
export const parseRotationErrorMessage = (err: unknown): string => {
let errorMessage = `Infisical encountered an issue while generating credentials with the configured inputs: `;
if (err instanceof AxiosError) {
errorMessage += err?.response?.data
? JSON.stringify(err?.response?.data)
: err?.message ?? "An unknown error occurred.";
} else {
errorMessage += (err as Error)?.message || "An unknown error occurred.";
}
return errorMessage.length <= MAX_MESSAGE_LENGTH
? errorMessage
: `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
};

View File

@@ -0,0 +1,12 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const SECRET_ROTATION_NAME_MAP: Record<SecretRotation, string> = {
[SecretRotation.PostgresCredentials]: "PostgreSQL Credentials",
[SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials"
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
[SecretRotation.PostgresCredentials]: AppConnection.Postgres,
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql
};

View File

@@ -0,0 +1,193 @@
import { ProjectMembershipRole } from "@app/db/schemas";
import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
getNextUtcRotationInterval,
getSecretRotationRotateSecretJobOptions
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
import {
TSecretRotationRotateSecretsJobPayload,
TSecretRotationSendNotificationJobPayload
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
type TSecretRotationV2QueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
secretRotationV2DAL: Pick<TSecretRotationV2DALFactory, "findSecretRotationsToQueue" | "findById">;
secretRotationV2Service: Pick<TSecretRotationV2ServiceFactory, "rotateGeneratedCredentials">;
smtpService: Pick<TSmtpService, "sendMail">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: Pick<TProjectDALFactory, "findById">;
};
export const secretRotationV2QueueServiceFactory = async ({
queueService,
secretRotationV2DAL,
secretRotationV2Service,
projectMembershipDAL,
projectDAL,
smtpService
}: TSecretRotationV2QueueServiceFactoryDep) => {
const appCfg = getConfig();
if (appCfg.isRotationDevelopmentMode) {
logger.warn("Secret Rotation V2 is in development mode.");
}
await queueService.startPg<QueueName.SecretRotationV2>(
QueueJobs.SecretRotationV2QueueRotations,
async () => {
try {
const rotateBy = getNextUtcRotationInterval();
const currentTime = new Date();
const secretRotations = await secretRotationV2DAL.findSecretRotationsToQueue(rotateBy);
logger.info(
`secretRotationV2Queue: Queue Rotations [currentTime=${currentTime.toISOString()}] [rotateBy=${rotateBy.toISOString()}] [count=${
secretRotations.length
}]`
);
for await (const rotation of secretRotations) {
logger.info(
`secretRotationV2Queue: Queue Rotation [rotationId=${rotation.id}] [lastRotatedAt=${new Date(
rotation.lastRotatedAt
).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
);
await queueService.queuePg(
QueueJobs.SecretRotationV2RotateSecrets,
{
rotationId: rotation.id,
queuedAt: currentTime
},
getSecretRotationRotateSecretJobOptions(rotation)
);
}
} catch (error) {
logger.error(error, "secretRotationV2Queue: Queue Rotations Error:");
throw error;
}
},
{
batchSize: 1,
workerCount: 1,
pollingIntervalSeconds: 0.5
}
);
await queueService.startPg<QueueName.SecretRotationV2>(
QueueJobs.SecretRotationV2RotateSecrets,
async ([job]) => {
const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload;
const { retryCount, retryLimit } = job;
const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`;
try {
const secretRotation = await secretRotationV2DAL.findById(rotationId);
if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`);
if (!secretRotation.isAutoRotationEnabled) {
logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`);
}
if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) {
// rotated since being queued, skip rotation
logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`);
return;
}
await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, {
jobId: job.id,
shouldSendNotification: true,
isFinalAttempt: retryCount === retryLimit,
isManualRotation
});
logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`);
} catch (error) {
logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`);
throw error;
}
},
{
batchSize: 1,
workerCount: 30,
pollingIntervalSeconds: 0.5
}
);
await queueService.startPg<QueueName.SecretRotationV2>(
QueueJobs.SecretRotationV2SendNotification,
async ([job]) => {
const { secretRotation } = job.data as TSecretRotationSendNotificationJobPayload;
try {
const {
name: rotationName,
type,
projectId,
lastRotationAttemptedAt,
folder,
environment,
id: rotationId
} = secretRotation;
logger.info(`secretRotationV2Queue: Sending Status Notification [rotationId=${rotationId}]`);
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
const project = await projectDAL.findById(projectId);
const projectAdmins = projectMembers.filter((member) =>
member.roles.some((role) => role.role === ProjectMembershipRole.Admin)
);
const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation];
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretRotationFailed,
subjectLine: `Secret Rotation Failed`,
substitutions: {
rotationName,
rotationType,
content: `Your ${rotationType} Rotation failed to rotate during it's scheduled rotation. The last rotation attempt occurred at ${new Date(
lastRotationAttemptedAt
).toISOString()}. Please check the rotation status in Infisical for more details.`,
secretPath: folder.path,
environment: environment.name,
projectName: project.name,
rotationUrl: encodeURI(`${appCfg.SITE_URL}/secret-manager/${projectId}/secrets/${environment.slug}`)
}
});
} catch (error) {
logger.error(
error,
`secretRotationV2Queue: Failed to Send Status Notification [rotationId=${secretRotation.id}]`
);
throw error;
}
},
{
batchSize: 1,
workerCount: 5,
pollingIntervalSeconds: 30
}
);
await queueService.schedulePg(
QueueJobs.SecretRotationV2QueueRotations,
appCfg.isRotationDevelopmentMode ? "* * * * *" : "0 0 * * *",
undefined,
{ tz: "UTC" }
);
};

View File

@@ -0,0 +1,76 @@
import { z } from "zod";
import { SecretRotationsV2Schema } from "@app/db/schemas/secret-rotations-v2";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
import { SecretRotations } from "@app/lib/api-docs";
import { removeTrailingSlash } from "@app/lib/fn";
import { slugSchema } from "@app/server/lib/schemas";
const RotateAtUtcSchema = z.object({
hours: z.number().min(0).max(23),
minutes: z.number().min(0).max(59)
});
export const BaseSecretRotationSchema = (type: SecretRotation) =>
SecretRotationsV2Schema.omit({
encryptedGeneratedCredentials: true,
encryptedLastRotationMessage: true,
rotateAtUtc: true,
// unique to provider
type: true,
parameters: true,
secretMappings: true
}).extend({
connection: z.object({
app: z.literal(SECRET_ROTATION_CONNECTION_MAP[type]),
name: z.string(),
id: z.string().uuid()
}),
environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }),
projectId: z.string(),
folder: z.object({ id: z.string(), path: z.string() }),
rotateAtUtc: RotateAtUtcSchema,
lastRotationMessage: z.string().nullish()
});
export const BaseCreateSecretRotationSchema = (type: SecretRotation) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretRotations.CREATE(type).name),
projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.CREATE(type).projectId),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(SecretRotations.CREATE(type).description),
connectionId: z.string().uuid().describe(SecretRotations.CREATE(type).connectionId),
environment: slugSchema({ field: "environment", max: 64 }).describe(SecretRotations.CREATE(type).environment),
secretPath: z
.string()
.trim()
.min(1, "Secret path required")
.transform(removeTrailingSlash)
.describe(SecretRotations.CREATE(type).secretPath),
isAutoRotationEnabled: z
.boolean()
.optional()
.default(true)
.describe(SecretRotations.CREATE(type).isAutoRotationEnabled),
rotationInterval: z.coerce.number().min(1).describe(SecretRotations.CREATE(type).rotationInterval),
rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.CREATE(type).rotateAtUtc)
});
export const BaseUpdateSecretRotationSchema = (type: SecretRotation) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretRotations.UPDATE(type).name).optional(),
description: z
.string()
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(SecretRotations.UPDATE(type).description),
isAutoRotationEnabled: z.boolean().optional().describe(SecretRotations.UPDATE(type).isAutoRotationEnabled),
rotationInterval: z.coerce.number().min(1).optional().describe(SecretRotations.UPDATE(type).rotationInterval),
rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.UPDATE(type).rotateAtUtc)
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import {
TMsSqlCredentialsRotation,
TMsSqlCredentialsRotationInput,
TMsSqlCredentialsRotationListItem,
TMsSqlCredentialsRotationWithConnection
} from "./mssql-credentials";
import {
TPostgresCredentialsRotation,
TPostgresCredentialsRotationInput,
TPostgresCredentialsRotationListItem,
TPostgresCredentialsRotationWithConnection
} from "./postgres-credentials";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation } from "./secret-rotation-v2-enums";
export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation;
export type TSecretRotationV2WithConnection =
| TPostgresCredentialsRotationWithConnection
| TMsSqlCredentialsRotationWithConnection;
export type TSecretRotationV2GeneratedCredentials = TSqlCredentialsRotationGeneratedCredentials;
export type TSecretRotationV2Input = TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput;
export type TSecretRotationV2ListItem = TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem;
export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>;
export type TListSecretRotationsV2ByProjectId = {
projectId: string;
type?: SecretRotation;
};
export type TFindSecretRotationV2ByIdDTO = {
rotationId: string;
type: SecretRotation;
};
export type TRotateSecretRotationV2 = TFindSecretRotationV2ByIdDTO & { auditLogInfo: AuditLogInfo };
export type TRotateAtUtc = { hours: number; minutes: number };
export type TFindSecretRotationV2ByNameDTO = {
rotationName: string;
secretPath: string;
environment: string;
projectId: string;
type: SecretRotation;
};
export type TCreateSecretRotationV2DTO = Pick<
TSecretRotationV2,
"parameters" | "secretsMapping" | "description" | "rotationInterval" | "name" | "connectionId" | "projectId"
> & {
type: SecretRotation;
secretPath: string;
environment: string;
isAutoRotationEnabled?: boolean;
rotateAtUtc?: TRotateAtUtc;
};
export type TUpdateSecretRotationV2DTO = Partial<
Omit<TCreateSecretRotationV2DTO, "projectId" | "connectionId" | "secretPath" | "environment">
> & {
rotationId: string;
type: SecretRotation;
};
export type TDeleteSecretRotationV2DTO = {
type: SecretRotation;
rotationId: string;
deleteSecrets: boolean;
revokeGeneratedCredentials: boolean;
};
export type TGetDashboardSecretRotationV2Count = {
search?: string;
projectId: string;
secretPath: string;
environments: string[];
};
export type TGetDashboardSecretRotationsV2 = {
search?: string;
projectId: string;
secretPath: string;
environments: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
limit: number;
offset: number;
};
export type TQuickSearchSecretRotationsV2Filters = {
offset?: number;
limit?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
};
export type TQuickSearchSecretRotationsV2 = {
projectId: string;
folderMappings: { folderId: string; path: string; environment: string }[];
filters: TQuickSearchSecretRotationsV2Filters;
};
export type TSecretRotationRotateGeneratedCredentials = {
auditLogInfo?: AuditLogInfo;
jobId?: string;
shouldSendNotification?: boolean;
isFinalAttempt?: boolean;
isManualRotation?: boolean;
};
export type TSecretRotationRotateSecretsJobPayload = { rotationId: string; queuedAt: Date; isManualRotation: boolean };
export type TSecretRotationSendNotificationJobPayload = {
secretRotation: TSecretRotationV2Raw;
};
// scott: the reason for the callback structure of the rotation factory is to facilitate, when possible,
// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the
// third party credential changes (when supported), preventing credentials getting out of sync
export type TRotationFactoryIssueCredentials = (
callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise<TSecretRotationV2Raw>
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryRevokeCredentials = (
generatedCredentials: TSecretRotationV2GeneratedCredentials,
callback: () => Promise<TSecretRotationV2Raw>
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryRotateCredentials = (
credentialsToRevoke: TSecretRotationV2GeneratedCredentials[number] | undefined,
callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise<TSecretRotationV2Raw>
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryGetSecretsPayload = (
generatedCredentials: TSecretRotationV2GeneratedCredentials[number]
) => { key: string; value: string }[];
export type TRotationFactory = (secretRotation: TSecretRotationV2WithConnection) => {
issueCredentials: TRotationFactoryIssueCredentials;
revokeCredentials: TRotationFactoryRevokeCredentials;
rotateCredentials: TRotationFactoryRotateCredentials;
getSecretsPayload: TRotationFactoryGetSecretsPayload;
};

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
export const SecretRotationV2Schema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema
]);

View File

@@ -0,0 +1,2 @@
export * from "./sql-credentials-rotation-fns";
export * from "./sql-credentials-rotation-schemas";

View File

@@ -0,0 +1,232 @@
import { randomInt } from "crypto";
import {
TRotationFactoryGetSecretsPayload,
TRotationFactoryIssueCredentials,
TRotationFactoryRevokeCredentials,
TRotationFactoryRotateCredentials
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql";
import {
TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationWithConnection
} from "./sql-credentials-rotation-types";
const DEFAULT_PASSWORD_REQUIREMENTS = {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
};
const generatePassword = () => {
try {
const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS;
const chars = {
lowercase: "abcdefghijklmnopqrstuvwxyz",
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: "0123456789",
symbols: allowedSymbols || "-_.~!*"
};
const parts: string[] = [];
if (required.lowercase > 0) {
parts.push(
...Array(required.lowercase)
.fill(0)
.map(() => chars.lowercase[randomInt(chars.lowercase.length)])
);
}
if (required.uppercase > 0) {
parts.push(
...Array(required.uppercase)
.fill(0)
.map(() => chars.uppercase[randomInt(chars.uppercase.length)])
);
}
if (required.digits > 0) {
parts.push(
...Array(required.digits)
.fill(0)
.map(() => chars.digits[randomInt(chars.digits.length)])
);
}
if (required.symbols > 0) {
parts.push(
...Array(required.symbols)
.fill(0)
.map(() => chars.symbols[randomInt(chars.symbols.length)])
);
}
const requiredTotal = Object.values(required).reduce<number>((a, b) => a + b, 0);
const remainingLength = Math.max(length - requiredTotal, 0);
const allowedChars = Object.entries(chars)
.filter(([key]) => required[key as keyof typeof required] > 0)
.map(([, value]) => value)
.join("");
parts.push(
...Array(remainingLength)
.fill(0)
.map(() => allowedChars[randomInt(allowedChars.length)])
);
// shuffle the array to mix up the characters
for (let i = parts.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
[parts[i], parts[j]] = [parts[j], parts[i]];
}
return parts.join("");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to generate password: ${message}`);
}
};
const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGeneratedCredentials) => {
const error = e as Error;
if (!error?.message) return "Unknown error";
let redactedMessage = error.message;
credentials.forEach(({ password }) => {
redactedMessage = redactedMessage.replaceAll(password, "*******************");
});
return redactedMessage;
};
export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRotationWithConnection) => {
const {
connection,
parameters: { username1, username2 },
activeIndex,
secretsMapping
} = secretRotation;
const validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => {
const client = await getSqlConnectionClient({
...connection,
credentials: {
...connection.credentials,
...credentials
}
});
try {
await client.raw("SELECT 1");
} catch (error) {
throw new Error(redactPasswords(error, [credentials]));
} finally {
await client.destroy();
}
};
const issueCredentials: TRotationFactoryIssueCredentials = async (callback) => {
const client = await getSqlConnectionClient(connection);
// For SQL, since we get existing users, we change both their passwords
// on issue to invalidate their existing passwords
const credentialsSet = [
{ username: username1, password: generatePassword() },
{ username: username2, password: generatePassword() }
];
try {
await client.transaction(async (tx) => {
for await (const credentials of credentialsSet) {
await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials));
}
});
} catch (error) {
throw new Error(redactPasswords(error, credentialsSet));
} finally {
await client.destroy();
}
for await (const credentials of credentialsSet) {
await validateCredentials(credentials);
}
return callback(credentialsSet[0]);
};
const revokeCredentials: TRotationFactoryRevokeCredentials = async (credentialsToRevoke, callback) => {
const client = await getSqlConnectionClient(connection);
const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() }));
try {
await client.transaction(async (tx) => {
for await (const credentials of revokedCredentials) {
// invalidate previous passwords
await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials));
}
});
} catch (error) {
throw new Error(redactPasswords(error, revokedCredentials));
} finally {
await client.destroy();
}
return callback();
};
const rotateCredentials: TRotationFactoryRotateCredentials = async (_, callback) => {
const client = await getSqlConnectionClient(connection);
// generate new password for the next active user
const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() };
try {
await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials));
} catch (error) {
throw new Error(redactPasswords(error, [credentials]));
} finally {
await client.destroy();
}
await validateCredentials(credentials);
return callback(credentials);
};
const getSecretsPayload: TRotationFactoryGetSecretsPayload = (generatedCredentials) => {
const { username, password } = secretsMapping;
const secrets = [
{
key: username,
value: generatedCredentials.username
},
{
key: password,
value: generatedCredentials.password
}
];
return secrets;
};
return {
issueCredentials,
revokeCredentials,
rotateCredentials,
getSecretsPayload,
validateCredentials
};
};

View File

@@ -0,0 +1,39 @@
import { z } from "zod";
import { SecretRotations } from "@app/lib/api-docs";
import { SecretNameSchema } from "@app/server/lib/schemas";
export const SqlCredentialsRotationGeneratedCredentialsSchema = z
.object({
username: z.string(),
password: z.string()
})
.array()
.min(1)
.max(2);
export const SqlCredentialsRotationParametersSchema = z.object({
username1: z
.string()
.trim()
.min(1, "Username1 Required")
.describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username1),
username2: z
.string()
.trim()
.min(1, "Username2 Required")
.describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username2)
});
export const SqlCredentialsRotationSecretsMappingSchema = z.object({
username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.username),
password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.password)
});
export const SqlCredentialsRotationTemplateSchema = z.object({
createUserStatement: z.string(),
secretsMapping: z.object({
username: z.string(),
password: z.string()
})
});

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
import { TMsSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mssql-credentials";
import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas";
export type TSqlCredentialsRotationWithConnection =
| TPostgresCredentialsRotationWithConnection
| TMsSqlCredentialsRotationWithConnection;
export type TSqlCredentialsRotationGeneratedCredentials = z.infer<
typeof SqlCredentialsRotationGeneratedCredentialsSchema
>;

View File

@@ -16,8 +16,8 @@ import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret
import { TLicenseServiceFactory } from "../license/license-service";
import { TPermissionServiceFactory } from "../permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSub
} from "../permission/project-permission";
import { TSecretRotationDALFactory } from "./secret-rotation-dal";
@@ -69,7 +69,10 @@ export const secretRotationServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretRotationActions.Read,
ProjectPermissionSub.SecretRotation
);
return {
custom: [],
@@ -99,7 +102,7 @@ export const secretRotationServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSecretRotationActions.Read,
ProjectPermissionSub.SecretRotation
);
@@ -208,7 +211,10 @@ export const secretRotationServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretRotationActions.Read,
ProjectPermissionSub.SecretRotation
);
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) {
const docs = await secretRotationDAL.findSecretV2({ projectId });
@@ -254,7 +260,10 @@ export const secretRotationServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretRotationActions.Edit,
ProjectPermissionSub.SecretRotation
);
await secretRotationQueue.removeFromQueue(doc.id, doc.interval);
await secretRotationQueue.addToQueue(doc.id, doc.interval);
return doc;
@@ -273,7 +282,7 @@ export const secretRotationServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSecretRotationActions.Delete,
ProjectPermissionSub.SecretRotation
);
const deletedDoc = await secretRotationDAL.transaction(async (tx) => {

View File

@@ -33,6 +33,7 @@ export const KeyStorePrefixes = {
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,
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const,
IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) =>
`identity-access-token-status:${identityAccessTokenId}`,

View File

@@ -1,3 +1,8 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
SECRET_ROTATION_CONNECTION_MAP,
SECRET_ROTATION_NAME_MAP
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
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";
@@ -817,7 +822,8 @@ export const DASHBOARD = {
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response.",
includeImports: "Whether to include project secret imports in the response."
includeImports: "Whether to include project secret imports in the response.",
includeSecretRotations: "Whether to include project secret rotations in the response."
},
SECRET_DETAILS_LIST: {
projectId: "The ID of the project to list secrets/folders from.",
@@ -832,7 +838,8 @@ export const DASHBOARD = {
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeImports: "Whether to include project secret imports in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
includeDynamicSecrets: "Whether to include dynamic project secrets in the response.",
includeSecretRotations: "Whether to include secret rotations in the response."
}
} as const;
@@ -1653,7 +1660,8 @@ export const AppConnections = {
name: `The name of the ${appName} Connection to create. Must be slug-friendly.`,
description: `An optional description for the ${appName} Connection.`,
credentials: `The credentials used to connect with ${appName}.`,
method: `The method used to authenticate with ${appName}.`
method: `The method used to authenticate with ${appName}.`,
isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.`
};
},
UPDATE: (app: AppConnection) => {
@@ -1663,12 +1671,23 @@ export const AppConnections = {
name: `The updated name of the ${appName} Connection. Must be slug-friendly.`,
description: `The updated description of the ${appName} Connection.`,
credentials: `The credentials used to connect with ${appName}.`,
method: `The method used to authenticate with ${appName}.`
method: `The method used to authenticate with ${appName}.`,
isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.`
};
},
DELETE: (app: AppConnection) => ({
connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.`
})
}),
CREDENTIALS: {
SQL_CONNECTION: {
host: "The hostname of the database server.",
port: "The port number of the database.",
database: "The name of the database to connect to.",
username: "The username to connect to the database with.",
password: "The password to connect to the database with.",
sslCertificate: "The SSL certificate to use for connection."
}
}
};
export const SecretSyncs = {
@@ -1785,3 +1804,70 @@ export const SecretSyncs = {
}
}
};
export const SecretRotations = {
LIST: (type?: SecretRotation) => ({
projectId: `The ID of the project to list ${type ? SECRET_ROTATION_NAME_MAP[type] : "Secret"} Rotations from.`
}),
GET_BY_ID: (type: SecretRotation) => ({
rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.`
}),
GET_GENERATED_CREDENTIALS_BY_ID: (type: SecretRotation) => ({
rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve the generated credentials for.`
}),
GET_BY_NAME: (type: SecretRotation) => ({
rotationName: `The name of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.`,
projectId: `The ID of the project the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.`,
secretPath: `The secret path the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located at.`,
environment: `The environment the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.`
}),
CREATE: (type: SecretRotation) => {
const destinationName = SECRET_ROTATION_NAME_MAP[type];
return {
name: `The name of the ${destinationName} Rotation to create. Must be slug-friendly.`,
description: `An optional description for the ${destinationName} Rotation.`,
projectId: "The ID of the project to create the rotation in.",
environment: `The slug of the project environment to create the rotation in.`,
secretPath: `The secret path of the project to create the rotation in.`,
connectionId: `The ID of the ${
APP_CONNECTION_NAME_MAP[SECRET_ROTATION_CONNECTION_MAP[type]]
} Connection to use for rotation.`,
isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`,
rotationInterval: `The interval, in days, to automatically rotate secrets.`,
rotateAtUtc: `The hours and minutes rotation should occur at in UTC. Defaults to Midnight (00:00) UTC.`
};
},
UPDATE: (type: SecretRotation) => {
const typeName = SECRET_ROTATION_NAME_MAP[type];
return {
rotationId: `The ID of the ${typeName} Rotation to be updated.`,
name: `The updated name of the ${typeName} Rotation. Must be slug-friendly.`,
description: `The updated description of the ${typeName} Rotation.`,
isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`,
rotationInterval: `The updated interval, in days, to automatically rotate secrets.`,
rotateAtUtc: `The updated hours and minutes rotation should occur at in UTC.`
};
},
DELETE: (type: SecretRotation) => ({
rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to be deleted.`,
deleteSecrets: `Whether the mapped secrets belonging to this rotation should be deleted.`,
revokeGeneratedCredentials: `Whether the generated credentials associated with this rotation should be revoked.`
}),
ROTATE: (type: SecretRotation) => ({
rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to rotate generated credentials for.`
}),
PARAMETERS: {
SQL_CREDENTIALS: {
username1:
"The username of the first login to rotate passwords for. This user must already exists in your database.",
username2:
"The username of the second login to rotate passwords for. This user must already exists in your database."
}
},
SECRETS_MAPPING: {
SQL_CREDENTIALS: {
username: "The name of the secret that the active username will be mapped to.",
password: "The name of the secret that the generated password will be mapped to."
}
}
};

View File

@@ -58,6 +58,7 @@ const envSchema = z
ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()),
QUEUE_WORKERS_ENABLED: zodStrBool.default("true"),
HTTPS_ENABLED: zodStrBool,
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
// smtp options
SMTP_HOST: zpStr(z.string().optional()),
SMTP_IGNORE_TLS: zodStrBool.default("false"),
@@ -262,6 +263,7 @@ const envSchema = z
isSmtpConfigured: Boolean(data.SMTP_HOST),
isRedisConfigured: Boolean(data.REDIS_URL),
isDevelopmentMode: data.NODE_ENV === "development",
isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE,
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
isSecretScanningConfigured:

View File

@@ -7,7 +7,7 @@ export const prependTableNameToFindFilter = (tableName: TableName, filterObj: ob
Object.fromEntries(
Object.entries(filterObj).map(([key, value]) =>
key.startsWith("$")
? [key, prependTableNameToFindFilter(tableName, value as object)]
? [key, value ? prependTableNameToFindFilter(tableName, value as object) : value]
: [`${tableName}.${key}`, value]
)
);

View File

@@ -4,6 +4,10 @@ import PgBoss, { WorkOptions } from "pg-boss";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import {
TSecretRotationRotateSecretsJobPayload,
TSecretRotationSendNotificationJobPayload
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import {
TScanFullRepoEventPayload,
TScanPushEventPayload
@@ -44,7 +48,8 @@ export enum QueueName {
ProjectV3Migration = "project-v3-migration",
AccessTokenStatusUpdate = "access-token-status-update",
ImportSecretsFromExternalSource = "import-secrets-from-external-source",
AppConnectionSecretSync = "app-connection-secret-sync"
AppConnectionSecretSync = "app-connection-secret-sync",
SecretRotationV2 = "secret-rotation-v2"
}
export enum QueueJobs {
@@ -73,7 +78,10 @@ export enum QueueJobs {
SecretSyncSyncSecrets = "secret-sync-sync-secrets",
SecretSyncImportSecrets = "secret-sync-import-secrets",
SecretSyncRemoveSecrets = "secret-sync-remove-secrets",
SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications"
SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications",
SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations",
SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets",
SecretRotationV2SendNotification = "secret-rotation-v2-send-notification"
}
export type TQueueJobTypes = {
@@ -213,6 +221,19 @@ export type TQueueJobTypes = {
name: QueueJobs.SecretSyncSendActionFailedNotifications;
payload: TQueueSendSecretSyncActionFailedNotificationsDTO;
};
[QueueName.SecretRotationV2]:
| {
name: QueueJobs.SecretRotationV2QueueRotations;
payload: undefined;
}
| {
name: QueueJobs.SecretRotationV2RotateSecrets;
payload: TSecretRotationRotateSecretsJobPayload;
}
| {
name: QueueJobs.SecretRotationV2SendNotification;
payload: TSecretRotationSendNotificationJobPayload;
};
};
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
@@ -229,6 +250,7 @@ export const queueServiceFactory = (
const pgBoss = new PgBoss({
connectionString: dbConnectionUrl,
archiveCompletedAfterSeconds: 60,
cronMonitorIntervalSeconds: 5,
archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried
deleteAfterSeconds: 30,
ssl: dbRootCert
@@ -247,15 +269,12 @@ export const queueServiceFactory = (
>;
const initialize = async () => {
const appCfg = getConfig();
if (appCfg.SHOULD_INIT_PG_QUEUE) {
logger.info("Initializing pg-queue...");
await pgBoss.start();
logger.info("Initializing pg-queue...");
await pgBoss.start();
pgBoss.on("error", (error) => {
logger.error(error, "pg-queue error");
});
}
pgBoss.on("error", (error) => {
logger.error(error, "pg-queue error");
});
};
const start = <T extends QueueName>(
@@ -283,7 +302,7 @@ export const queueServiceFactory = (
const startPg = async <T extends QueueName>(
jobName: QueueJobs,
jobsFn: (jobs: PgBoss.Job<TQueueJobTypes[T]["payload"]>[]) => Promise<void>,
jobsFn: (jobs: PgBoss.JobWithMetadata<TQueueJobTypes[T]["payload"]>[]) => Promise<void>,
options: WorkOptions & {
workerCount: number;
}
@@ -297,7 +316,7 @@ export const queueServiceFactory = (
await Promise.all(
Array.from({ length: options.workerCount }).map(() =>
pgBoss.work<TQueueJobTypes[T]["payload"]>(jobName, options, jobsFn)
pgBoss.work<TQueueJobTypes[T]["payload"]>(jobName, { ...options, includeMetadata: true }, jobsFn)
)
);
};
@@ -342,6 +361,15 @@ export const queueServiceFactory = (
});
};
const schedulePg = async <T extends QueueName>(
job: TQueueJobTypes[T]["name"],
cron: string,
data: TQueueJobTypes[T]["payload"],
opts?: PgBoss.ScheduleOptions & { jobId?: string }
) => {
await pgBoss.schedule(job, cron, data, opts);
};
const stopRepeatableJob = async <T extends QueueName>(
name: T,
job: TQueueJobTypes[T]["name"],
@@ -403,6 +431,7 @@ export const queueServiceFactory = (
stopJobById,
getRepeatableJobs,
startPg,
queuePg
queuePg,
schedulePg
};
};

View File

@@ -39,3 +39,10 @@ export const GenericResourceNameSchema = z
])(val),
"Name can only contain alphanumeric characters, dashes, underscores, and spaces"
);
export const BaseSecretNameSchema = z.string().trim().min(1);
export const SecretNameSchema = BaseSecretNameSchema.refine(
(el) => !el.includes(" "),
"Secret name cannot contain spaces."
).refine((el) => !el.includes(":"), "Secret name cannot contain colon.");

View File

@@ -76,6 +76,9 @@ import { secretReplicationServiceFactory } from "@app/ee/services/secret-replica
import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue";
import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
import { secretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
import { secretRotationV2QueueServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-queue";
import { secretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
import { gitAppDALFactory } from "@app/ee/services/secret-scanning/git-app-dal";
import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning/git-app-install-session-dal";
import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal";
@@ -406,6 +409,8 @@ export const registerRoutes = async (
const gatewayDAL = gatewayDALFactory(db);
const projectGatewayDAL = projectGatewayDALFactory(db);
const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL);
const permissionService = permissionServiceFactory({
permissionDAL,
orgRoleDAL,
@@ -1497,6 +1502,35 @@ export const registerRoutes = async (
permissionService
});
const secretRotationV2Service = secretRotationV2ServiceFactory({
secretRotationV2DAL,
permissionService,
appConnectionService,
folderDAL,
projectBotService,
licenseService,
kmsService,
auditLogService,
secretV2BridgeDAL,
secretTagDAL,
secretVersionTagV2BridgeDAL,
secretVersionV2BridgeDAL,
keyStore,
resourceMetadataDAL,
snapshotService,
secretQueueService,
queueService
});
await secretRotationV2QueueServiceFactory({
secretRotationV2Service,
secretRotationV2DAL,
queueService,
projectDAL,
projectMembershipDAL,
smtpService
});
await superAdminService.initServerCfg();
// setup the communication with license key server
@@ -1598,7 +1632,8 @@ export const registerRoutes = async (
secretSync: secretSyncService,
kmip: kmipService,
kmipOperation: kmipOperationService,
gateway: gatewayService
gateway: gatewayService,
secretRotationV2: secretRotationV2Service
});
const cronJobs: CronJob[] = [];

View File

@@ -134,7 +134,9 @@ export const secretRawSchema = z.object({
membershipId: z.string().nullable().optional()
})
.optional()
.nullable()
.nullable(),
isRotatedSecret: z.boolean().optional(),
rotationId: z.string().uuid().nullish()
});
export const ProjectPermissionSchema = z.object({

View File

@@ -24,8 +24,14 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
method: I["method"];
credentials: I["credentials"];
description?: string | null;
isPlatformManagedCredentials?: boolean;
}>;
updateSchema: z.ZodType<{
name?: string;
credentials?: I["credentials"];
description?: string | null;
isPlatformManagedCredentials?: boolean;
}>;
updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>;
sanitizedResponseSchema: z.ZodTypeAny;
}) => {
const appName = APP_CONNECTION_NAME_MAP[app];
@@ -208,10 +214,10 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { name, method, credentials, description } = req.body;
const { name, method, credentials, description, isPlatformManagedCredentials } = req.body;
const appConnection = (await server.services.appConnection.createAppConnection(
{ name, method, app, credentials, description },
{ name, method, app, credentials, description, isPlatformManagedCredentials },
req.permission
)) as T;
@@ -224,7 +230,8 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
name,
method,
app,
connectionId: appConnection.id
connectionId: appConnection.id,
isPlatformManagedCredentials
}
}
});
@@ -251,11 +258,11 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { name, credentials, description } = req.body;
const { name, credentials, description, isPlatformManagedCredentials } = req.body;
const { connectionId } = req.params;
const appConnection = (await server.services.appConnection.updateAppConnection(
{ name, credentials, connectionId, description },
{ name, credentials, connectionId, description, isPlatformManagedCredentials },
req.permission
)) as T;
@@ -268,7 +275,8 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten
name,
description,
credentialsUpdated: Boolean(credentials),
connectionId
connectionId,
isPlatformManagedCredentials
}
}
});

View File

@@ -22,6 +22,11 @@ import {
HumanitecConnectionListItemSchema,
SanitizedHumanitecConnectionSchema
} from "@app/services/app-connection/humanitec";
import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql";
import {
PostgresConnectionListItemSchema,
SanitizedPostgresConnectionSchema
} from "@app/services/app-connection/postgres";
import { AuthMode } from "@app/services/auth/auth-type";
// can't use discriminated due to multiple schemas for certain apps
@@ -32,7 +37,9 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedAzureKeyVaultConnectionSchema.options,
...SanitizedAzureAppConfigurationConnectionSchema.options,
...SanitizedDatabricksConnectionSchema.options,
...SanitizedHumanitecConnectionSchema.options
...SanitizedHumanitecConnectionSchema.options,
...SanitizedPostgresConnectionSchema.options,
...SanitizedMsSqlConnectionSchema.options
]);
const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
@@ -42,7 +49,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
AzureKeyVaultConnectionListItemSchema,
AzureAppConfigurationConnectionListItemSchema,
DatabricksConnectionListItemSchema,
HumanitecConnectionListItemSchema
HumanitecConnectionListItemSchema,
PostgresConnectionListItemSchema,
MsSqlConnectionListItemSchema
]);
export const registerAppConnectionRouter = async (server: FastifyZodProvider) => {

View File

@@ -7,6 +7,8 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout
import { registerGcpConnectionRouter } from "./gcp-connection-router";
import { registerGitHubConnectionRouter } from "./github-connection-router";
import { registerHumanitecConnectionRouter } from "./humanitec-connection-router";
import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
import { registerPostgresConnectionRouter } from "./postgres-connection-router";
export * from "./app-connection-router";
@@ -18,5 +20,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.AzureKeyVault]: registerAzureKeyVaultConnectionRouter,
[AppConnection.AzureAppConfiguration]: registerAzureAppConfigurationConnectionRouter,
[AppConnection.Databricks]: registerDatabricksConnectionRouter,
[AppConnection.Humanitec]: registerHumanitecConnectionRouter
[AppConnection.Humanitec]: registerHumanitecConnectionRouter,
[AppConnection.Postgres]: registerPostgresConnectionRouter,
[AppConnection.MsSql]: registerMsSqlConnectionRouter
};

View File

@@ -0,0 +1,18 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateMsSqlConnectionSchema,
SanitizedMsSqlConnectionSchema,
UpdateMsSqlConnectionSchema
} from "@app/services/app-connection/mssql";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerMsSqlConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.MsSql,
server,
sanitizedResponseSchema: SanitizedMsSqlConnectionSchema,
createSchema: CreateMsSqlConnectionSchema,
updateSchema: UpdateMsSqlConnectionSchema
});
};

View File

@@ -0,0 +1,18 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreatePostgresConnectionSchema,
SanitizedPostgresConnectionSchema,
UpdatePostgresConnectionSchema
} from "@app/services/app-connection/postgres";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerPostgresConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Postgres,
server,
sanitizedResponseSchema: SanitizedPostgresConnectionSchema,
createSchema: CreatePostgresConnectionSchema,
updateSchema: UpdatePostgresConnectionSchema
});
};

View File

@@ -8,6 +8,7 @@ import {
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
@@ -101,12 +102,30 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets),
includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders),
includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports),
includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecretRotations),
includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets)
}),
response: {
200: z.object({
folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(),
secretRotations: z
.intersection(
SecretRotationV2Schema,
z.object({
secrets: secretRawSchema
.extend({
secretValueHidden: z.boolean(),
secretPath: z.string().optional(),
secretMetadata: ResourceMetadataSchema.optional(),
tags: SanitizedTagSchema.array().optional()
})
.nullable()
.array()
})
)
.array()
.optional(),
secrets: secretRawSchema
.extend({
secretValueHidden: z.boolean(),
@@ -127,6 +146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalImportCount: z.number().optional(),
totalSecretRotationCount: z.number().optional(),
totalCount: z.number()
})
}
@@ -144,7 +164,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeFolders,
includeSecrets,
includeImports,
includeDynamicSecrets
includeDynamicSecrets,
includeSecretRotations
} = req.query;
const environments = req.query.environments.split(",");
@@ -166,11 +187,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let dynamicSecrets:
| Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnvs>>
| undefined;
let secretRotations:
| Awaited<ReturnType<typeof server.services.secretRotationV2.getDashboardSecretRotations>>
| undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
let totalImportCount: number | undefined;
let totalSecretRotationCount: number | undefined;
if (includeImports) {
totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({
@@ -322,6 +347,56 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
if (includeSecretRotations) {
totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount(
{
projectId,
search,
environments,
secretPath
},
req.permission
);
if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) {
secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations(
{
projectId,
search,
orderBy,
orderDirection,
environments,
secretPath,
limit: remainingLimit,
offset: adjustedOffset
},
req.permission
);
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRET_ROTATIONS,
metadata: {
count: secretRotations.length,
rotationIds: secretRotations.map((rotation) => rotation.id),
secretPath,
environment: environments.join(",")
}
}
});
// get the count of unique secret rotation names to properly adjust remaining limit
const uniqueSecretRotationCount = new Set(secretRotations.map((rotation) => rotation.name)).size;
remainingLimit -= uniqueSecretRotationCount;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount);
}
}
if (includeSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({
@@ -353,38 +428,44 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
offset: adjustedOffset,
isInternal: true
});
}
}
for await (const environment of environments) {
const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length;
if (secrets?.length || secretRotations?.length) {
for await (const environment of environments) {
const secretCountFromEnv =
(secrets?.filter((secret) => secret.environment === environment).length ?? 0) +
(secretRotations
?.filter((rotation) => rotation.environment.slug === environment)
.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0);
if (secretCountFromEnv) {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secretCountFromEnv
}
if (secretCountFromEnv) {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secretCountFromEnv
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secretCountFromEnv,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secretCountFromEnv,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
}
@@ -395,12 +476,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
dynamicSecrets,
secrets,
imports,
secretRotations,
totalFolderCount,
totalDynamicSecretCount,
totalImportCount,
totalSecretCount,
totalSecretRotationCount,
totalCount:
(totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0)
(totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) +
(totalSecretCount ?? 0) +
(totalImportCount ?? 0) +
(totalSecretRotationCount ?? 0)
};
}
});
@@ -445,7 +532,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets),
includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders),
includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets),
includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports)
includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports),
includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecretRotations)
}),
response: {
200: z.object({
@@ -457,6 +545,23 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
.optional(),
folders: SecretFoldersSchema.array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(),
secretRotations: z
.intersection(
SecretRotationV2Schema,
z.object({
secrets: secretRawSchema
.extend({
secretValueHidden: z.boolean(),
secretPath: z.string().optional(),
secretMetadata: ResourceMetadataSchema.optional(),
tags: SanitizedTagSchema.array().optional()
})
.nullable()
.array()
})
)
.array()
.optional(),
secrets: secretRawSchema
.extend({
secretValueHidden: z.boolean(),
@@ -470,6 +575,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalSecretRotationCount: z.number().optional(),
totalCount: z.number()
})
}
@@ -488,7 +594,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
includeFolders,
includeSecrets,
includeDynamicSecrets,
includeImports
includeImports,
includeSecretRotations
} = req.query;
if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" });
@@ -507,11 +614,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let folders: Awaited<ReturnType<typeof server.services.folder.getFolders>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRaw>>["secrets"] | undefined;
let dynamicSecrets: Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnv>> | undefined;
let secretRotations:
| Awaited<ReturnType<typeof server.services.secretRotationV2.getDashboardSecretRotations>>
| undefined;
let totalImportCount: number | undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
let totalSecretRotationCount: number | undefined;
if (includeImports) {
totalImportCount = await server.services.secretImport.getProjectImportCount({
@@ -594,6 +705,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
if (includeSecretRotations) {
totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount(
{
projectId,
search,
environments: [environment],
secretPath
},
req.permission
);
if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) {
secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations(
{
projectId,
search,
orderBy,
orderDirection,
environments: [environment],
secretPath,
limit: remainingLimit,
offset: adjustedOffset
},
req.permission
);
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRET_ROTATIONS,
metadata: {
count: secretRotations.length,
rotationIds: secretRotations.map((rotation) => rotation.id),
secretPath,
environment
}
}
});
remainingLimit -= secretRotations.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount);
}
}
try {
if (includeDynamicSecrets) {
totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({
@@ -629,7 +787,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
}
}
} catch (error) {
if (!(error instanceof ForbiddenError)) {
throw error;
}
}
try {
if (includeSecrets) {
totalSecretCount = await server.services.secret.getSecretsCount({
actorId: req.permission.id,
@@ -663,34 +827,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
tagSlugs: tags
})
).secrets;
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secrets.length
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secrets.length,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
} catch (error) {
@@ -699,17 +835,57 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}
}
if (secrets?.length || secretRotations?.length) {
const secretCount =
(secrets?.length ?? 0) +
(secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0);
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secretCount
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secretCount,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
return {
imports,
folders,
dynamicSecrets,
secrets,
secretRotations,
totalImportCount,
totalFolderCount,
totalDynamicSecretCount,
totalSecretCount,
totalSecretRotationCount,
totalCount:
(totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
(totalImportCount ?? 0) +
(totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) +
(totalSecretCount ?? 0) +
(totalSecretRotationCount ?? 0)
};
}
});
@@ -747,7 +923,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
tags: SanitizedTagSchema.array().optional()
})
.array()
.optional()
.optional(),
secretRotations: SecretRotationV2Schema.array().optional()
})
}
},
@@ -811,6 +988,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
req.permission
);
const secretRotations = searchHasTags
? []
: await server.services.secretRotationV2.getQuickSearchSecretRotations(
{
projectId,
folderMappings,
filters: sharedFilters
},
req.permission
);
for await (const environment of environments) {
const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length;
@@ -843,6 +1031,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
});
}
}
const secretRotationsFromEnv = secretRotations.filter((rotation) => rotation.environment.slug === environment);
if (secretRotationsFromEnv.length) {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRET_ROTATIONS,
metadata: {
count: secretRotationsFromEnv.length,
rotationIds: secretRotationsFromEnv.map((rotation) => rotation.id),
secretPath,
environment
}
}
});
}
}
const sliceQuickSearch = <T>(array: T[]) => array.slice(0, 25);
@@ -856,6 +1062,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
? dynamicSecrets.filter((dynamicSecret) => dynamicSecret.path.endsWith(searchPath))
: dynamicSecrets
),
secretRotations: sliceQuickSearch(
searchPath ? secretRotations.filter((rotation) => rotation.folder.path.endsWith(searchPath)) : secretRotations
),
folders: searchHasTags
? []
: sliceQuickSearch(

View File

@@ -7,6 +7,7 @@ import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { secretsLimit, writeLimit } from "@app/server/config/rateLimiter";
import { BaseSecretNameSchema, SecretNameSchema } from "@app/server/lib/schemas";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { getUserAgentType } from "@app/server/plugins/audit-log";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -39,13 +40,6 @@ const SecretReferenceNodeTree: z.ZodType<TSecretReferenceNode> = SecretReference
children: z.lazy(() => SecretReferenceNodeTree.array())
});
const BaseSecretNameSchema = z.string().trim().min(1);
const SecretNameSchema = BaseSecretNameSchema.refine(
(el) => !el.includes(" "),
"Secret name cannot contain spaces."
).refine((el) => !el.includes(":"), "Secret name cannot contain colon.");
export const registerSecretRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
@@ -630,6 +624,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretValue: z
.string()
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
.optional()
.describe(RAW_SECRETS.UPDATE.secretValue),
secretPath: z
.string()
@@ -2049,6 +2044,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretValue: z
.string()
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
.optional()
.describe(RAW_SECRETS.UPDATE.secretValue),
secretPath: z
.string()

View File

@@ -5,7 +5,9 @@ export enum AppConnection {
GCP = "gcp",
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Humanitec = "humanitec"
Humanitec = "humanitec",
Postgres = "postgres",
MsSql = "mssql"
}
export enum AWSRegion {

View File

@@ -1,30 +1,22 @@
import { TAppConnections } from "@app/db/schemas/app-connections";
import { generateHash } from "@app/lib/crypto/encryption";
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";
import { BadRequestError } from "@app/lib/errors";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import {
AwsConnectionMethod,
getAwsConnectionListItem,
validateAwsConnectionCredentials
} from "@app/services/app-connection/aws";
import {
DatabricksConnectionMethod,
getDatabricksConnectionListItem,
validateDatabricksConnectionCredentials
} from "@app/services/app-connection/databricks";
import {
GcpConnectionMethod,
getGcpConnectionListItem,
validateGcpConnectionCredentials
} from "@app/services/app-connection/gcp";
import {
getGitHubConnectionListItem,
GitHubConnectionMethod,
validateGitHubConnectionCredentials
} from "@app/services/app-connection/github";
transferSqlConnectionCredentialsToPlatform,
validateSqlConnectionCredentials
} from "@app/services/app-connection/shared/sql";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { AppConnection } from "./app-connection-enums";
import { TAppConnectionServiceFactoryDep } from "./app-connection-service";
import {
TAppConnection,
TAppConnectionConfig,
TAppConnectionCredentialsValidator,
TAppConnectionTransitionCredentialsToPlatform
} from "./app-connection-types";
import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws";
import {
AzureAppConfigurationConnectionMethod,
getAzureAppConfigurationConnectionListItem,
@@ -35,11 +27,20 @@ import {
getAzureKeyVaultConnectionListItem,
validateAzureKeyVaultConnectionCredentials
} from "./azure-key-vault";
import {
DatabricksConnectionMethod,
getDatabricksConnectionListItem,
validateDatabricksConnectionCredentials
} from "./databricks";
import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp";
import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github";
import {
getHumanitecConnectionListItem,
HumanitecConnectionMethod,
validateHumanitecConnectionCredentials
} from "./humanitec";
import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql";
import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres";
export const listAppConnectionOptions = () => {
return [
@@ -49,7 +50,9 @@ export const listAppConnectionOptions = () => {
getAzureKeyVaultConnectionListItem(),
getAzureAppConfigurationConnectionListItem(),
getDatabricksConnectionListItem(),
getHumanitecConnectionListItem()
getHumanitecConnectionListItem(),
getPostgresConnectionListItem(),
getMsSqlConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -95,30 +98,22 @@ export const decryptAppConnectionCredentials = async ({
return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"];
};
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnectionCredentialsValidator> = {
[AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureAppConfiguration]:
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator
};
export const validateAppConnectionCredentials = async (
appConnection: TAppConnectionConfig
): Promise<TAppConnection["credentials"]> => {
const { app } = appConnection;
switch (app) {
case AppConnection.AWS:
return validateAwsConnectionCredentials(appConnection);
case AppConnection.Databricks:
return validateDatabricksConnectionCredentials(appConnection);
case AppConnection.GitHub:
return validateGitHubConnectionCredentials(appConnection);
case AppConnection.GCP:
return validateGcpConnectionCredentials(appConnection);
case AppConnection.AzureKeyVault:
return validateAzureKeyVaultConnectionCredentials(appConnection);
case AppConnection.AzureAppConfiguration:
return validateAzureAppConfigurationConnectionCredentials(appConnection);
case AppConnection.Humanitec:
return validateHumanitecConnectionCredentials(appConnection);
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection ${app}`);
}
};
): Promise<TAppConnection["credentials"]> => VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
export const getAppConnectionMethodName = (method: TAppConnection["method"]) => {
switch (method) {
@@ -136,8 +131,11 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
return "Service Account Impersonation";
case DatabricksConnectionMethod.ServicePrincipal:
return "Service Principal";
case HumanitecConnectionMethod.API_TOKEN:
case HumanitecConnectionMethod.ApiToken:
return "API Token";
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
return "Username & Password";
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection Method: ${method}`);
@@ -158,3 +156,24 @@ export const decryptAppConnection = async (
credentialsHash: generateHash(appConnection.encryptedCredentials)
} as TAppConnection;
};
const platformManagedCredentialsNotSupported: TAppConnectionTransitionCredentialsToPlatform = ({ app }) => {
throw new BadRequestError({
message: `${APP_CONNECTION_NAME_MAP[app]} Connections do not support platform managed credentials.`
});
};
export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
AppConnection,
TAppConnectionTransitionCredentialsToPlatform
> = {
[AppConnection.AWS]: platformManagedCredentialsNotSupported,
[AppConnection.Databricks]: platformManagedCredentialsNotSupported,
[AppConnection.GitHub]: platformManagedCredentialsNotSupported,
[AppConnection.GCP]: platformManagedCredentialsNotSupported,
[AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported,
[AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported,
[AppConnection.Humanitec]: platformManagedCredentialsNotSupported,
[AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform
};

View File

@@ -7,5 +7,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AzureKeyVault]: "Azure Key Vault",
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
[AppConnection.Databricks]: "Databricks",
[AppConnection.Humanitec]: "Humanitec"
[AppConnection.Humanitec]: "Humanitec",
[AppConnection.Postgres]: "PostgreSQL",
[AppConnection.MsSql]: "Microsoft SQL Server"
};

View File

@@ -3,6 +3,8 @@ import { z } from "zod";
import { AppConnectionsSchema } from "@app/db/schemas/app-connections";
import { AppConnections } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import { TAppConnectionBaseConfig } from "@app/services/app-connection/app-connection-types";
import { AppConnection } from "./app-connection-enums";
@@ -14,7 +16,10 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({
credentialsHash: z.string().optional()
});
export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) =>
export const GenericCreateAppConnectionFieldsSchema = (
app: AppConnection,
{ supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {}
) =>
z.object({
name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name),
description: z
@@ -22,10 +27,16 @@ export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) =>
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(AppConnections.CREATE(app).description)
.describe(AppConnections.CREATE(app).description),
isPlatformManagedCredentials: supportsPlatformManagedCredentials
? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials)
: z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`)
});
export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) =>
export const GenericUpdateAppConnectionFieldsSchema = (
app: AppConnection,
{ supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {}
) =>
z.object({
name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(),
description: z
@@ -33,5 +44,8 @@ export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) =>
.trim()
.max(256, "Description cannot exceed 256 characters")
.nullish()
.describe(AppConnections.UPDATE(app).description)
.describe(AppConnections.UPDATE(app).description),
isPlatformManagedCredentials: supportsPlatformManagedCredentials
? z.boolean().optional().describe(AppConnections.UPDATE(app).isPlatformManagedCredentials)
: z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`)
});

View File

@@ -6,25 +6,27 @@ import { generateHash } from "@app/lib/crypto/encryption";
import { DatabaseErrorCode } from "@app/lib/error-codes";
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 {
decryptAppConnection,
encryptAppConnectionCredentials,
getAppConnectionMethodName,
listAppConnectionOptions,
TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM,
validateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-fns";
import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps";
import {
TAppConnection,
TAppConnectionConfig,
TCreateAppConnectionDTO,
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
import { AppConnection } from "./app-connection-enums";
import { APP_CONNECTION_NAME_MAP } from "./app-connection-maps";
import {
TAppConnection,
TAppConnectionConfig,
TAppConnectionRaw,
TCreateAppConnectionDTO,
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentials
} from "./app-connection-types";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
@@ -37,6 +39,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec";
import { humanitecConnectionService } from "./humanitec/humanitec-connection-service";
import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql";
import { ValidatePostgresConnectionCredentialsSchema } from "./postgres";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@@ -53,7 +57,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema,
[AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema,
[AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema,
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema,
[AppConnection.Postgres]: ValidatePostgresConnectionCredentialsSchema,
[AppConnection.MsSql]: ValidateMsSqlConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -163,20 +169,42 @@ export const appConnectionServiceFactory = ({
orgId: actor.orgId
} as TAppConnectionConfig);
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
});
try {
const connection = await appConnectionDAL.create({
orgId: actor.orgId,
encryptedCredentials,
method,
app,
...params
});
const createTransaction = (connectionCredentials: TAppConnection["credentials"]) =>
appConnectionDAL.transaction(async (tx) => {
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: connectionCredentials,
orgId: actor.orgId,
kmsService
});
return appConnectionDAL.create(
{
orgId: actor.orgId,
encryptedCredentials,
method,
app,
...params
},
tx
);
});
let connection: TAppConnectionRaw;
if (params.isPlatformManagedCredentials) {
connection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app](
{
app,
orgId: actor.orgId,
credentials: validatedCredentials,
method
} as TAppConnectionConfig,
(platformCredentials) => createTransaction(platformCredentials)
);
} else {
connection = await createTransaction(validatedCredentials);
}
return {
...connection,
@@ -213,11 +241,18 @@ export const appConnectionServiceFactory = ({
OrgPermissionSubjects.AppConnections
);
let encryptedCredentials: undefined | Buffer;
// prevent updating credentials or management status if platform managed
if (appConnection.isPlatformManagedCredentials && (params.isPlatformManagedCredentials === false || credentials)) {
throw new BadRequestError({
message: "Cannot update credentials or management status for platform managed connections"
});
}
let updatedCredentials: undefined | TAppConnection["credentials"];
const { app, method } = appConnection as DiscriminativePick<TAppConnectionConfig, "app" | "method">;
if (credentials) {
const { app, method } = appConnection as DiscriminativePick<TAppConnectionConfig, "app" | "method">;
if (
!VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({
method,
@@ -230,29 +265,58 @@ export const appConnectionServiceFactory = ({
} Connection with method ${getAppConnectionMethodName(method)}`
});
const validatedCredentials = await validateAppConnectionCredentials({
updatedCredentials = await validateAppConnectionCredentials({
app,
orgId: actor.orgId,
credentials,
method
} as TAppConnectionConfig);
if (!validatedCredentials)
if (!updatedCredentials)
throw new BadRequestError({ message: "Unable to validate connection - check credentials" });
encryptedCredentials = await encryptAppConnectionCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
});
}
try {
const updatedConnection = await appConnectionDAL.updateById(connectionId, {
orgId: actor.orgId,
encryptedCredentials,
...params
});
const updateTransaction = (connectionCredentials: TAppConnection["credentials"] | undefined) =>
appConnectionDAL.transaction(async (tx) => {
const encryptedCredentials = connectionCredentials
? await encryptAppConnectionCredentials({
credentials: connectionCredentials,
orgId: actor.orgId,
kmsService
})
: undefined;
return appConnectionDAL.updateById(
connectionId,
{
orgId: actor.orgId,
encryptedCredentials,
...params
},
tx
);
});
let updatedConnection: TAppConnectionRaw;
if (params.isPlatformManagedCredentials) {
if (!updatedCredentials)
// prevent enabling platform managed credentials without re-confirming credentials
throw new BadRequestError({ message: "Credentials required to transition to platform managed credentials" });
updatedConnection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app](
{
app,
orgId: actor.orgId,
credentials: updatedCredentials,
method
} as TAppConnectionConfig,
(platformCredentials) => updateTransaction(platformCredentials)
);
} else {
updatedConnection = await updateTransaction(updatedCredentials);
}
return await decryptAppConnection(updatedConnection, kmsService);
} catch (err) {

View File

@@ -1,24 +1,9 @@
import { AWSRegion } from "@app/services/app-connection/app-connection-enums";
import {
TAwsConnection,
TAwsConnectionConfig,
TAwsConnectionInput,
TValidateAwsConnectionCredentials
} from "@app/services/app-connection/aws";
import {
TDatabricksConnection,
TDatabricksConnectionConfig,
TDatabricksConnectionInput,
TValidateDatabricksConnectionCredentials
} from "@app/services/app-connection/databricks";
import {
TGitHubConnection,
TGitHubConnectionConfig,
TGitHubConnectionInput,
TValidateGitHubConnectionCredentials
} from "@app/services/app-connection/github";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { AWSRegion } from "./app-connection-enums";
import { TAwsConnection, TAwsConnectionConfig, TAwsConnectionInput, TValidateAwsConnectionCredentials } from "./aws";
import {
TAzureAppConfigurationConnection,
TAzureAppConfigurationConnectionConfig,
@@ -31,13 +16,27 @@ import {
TAzureKeyVaultConnectionInput,
TValidateAzureKeyVaultConnectionCredentials
} from "./azure-key-vault";
import {
TDatabricksConnection,
TDatabricksConnectionConfig,
TDatabricksConnectionInput,
TValidateDatabricksConnectionCredentials
} from "./databricks";
import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp";
import {
TGitHubConnection,
TGitHubConnectionConfig,
TGitHubConnectionInput,
TValidateGitHubConnectionCredentials
} from "./github";
import {
THumanitecConnection,
THumanitecConnectionConfig,
THumanitecConnectionInput,
TValidateHumanitecConnectionCredentials
} from "./humanitec";
import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentials } from "./mssql";
import { TPostgresConnection, TPostgresConnectionInput, TValidatePostgresConnectionCredentials } from "./postgres";
export type TAppConnection = { id: string } & (
| TAwsConnection
@@ -47,8 +46,14 @@ export type TAppConnection = { id: string } & (
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection
| TPostgresConnection
| TMsSqlConnection
);
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
export type TSqlConnection = TPostgresConnection | TMsSqlConnection;
export type TAppConnectionInput = { id: string } & (
| TAwsConnectionInput
| TGitHubConnectionInput
@@ -57,11 +62,15 @@ export type TAppConnectionInput = { id: string } & (
| TAzureAppConfigurationConnectionInput
| TDatabricksConnectionInput
| THumanitecConnectionInput
| TPostgresConnectionInput
| TMsSqlConnectionInput
);
export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput;
export type TCreateAppConnectionDTO = Pick<
TAppConnectionInput,
"credentials" | "method" | "name" | "app" | "description"
"credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials"
>;
export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "method" | "app">> & {
@@ -75,7 +84,8 @@ export type TAppConnectionConfig =
| TAzureKeyVaultConnectionConfig
| TAzureAppConfigurationConnectionConfig
| TDatabricksConnectionConfig
| THumanitecConnectionConfig;
| THumanitecConnectionConfig
| TSqlConnectionConfig;
export type TValidateAppConnectionCredentials =
| TValidateAwsConnectionCredentials
@@ -84,10 +94,25 @@ export type TValidateAppConnectionCredentials =
| TValidateAzureKeyVaultConnectionCredentials
| TValidateAzureAppConfigurationConnectionCredentials
| TValidateDatabricksConnectionCredentials
| TValidateHumanitecConnectionCredentials;
| TValidateHumanitecConnectionCredentials
| TValidatePostgresConnectionCredentials
| TValidateMsSqlConnectionCredentials;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
region: AWSRegion;
destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager;
};
export type TAppConnectionCredentialsValidator = (
appConnection: TAppConnectionConfig
) => Promise<TAppConnection["credentials"]>;
export type TAppConnectionTransitionCredentialsToPlatform = (
appConnection: TAppConnectionConfig,
callback: (credentials: TAppConnection["credentials"]) => Promise<TAppConnectionRaw>
) => Promise<TAppConnectionRaw>;
export type TAppConnectionBaseConfig = {
supportsPlatformManagedCredentials?: boolean;
};

View File

@@ -92,7 +92,7 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec
resp = await sts.getCallerIdentity().promise();
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection - verify credentials`
message: `Unable to validate connection: verify credentials`
});
}

View File

@@ -48,11 +48,11 @@ export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method),
method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials)
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method),
method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)

View File

@@ -57,7 +57,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async (
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection - verify credentials`
message: `Unable to validate connection: verify credentials`
});
}
}

View File

@@ -129,7 +129,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection - verify credentials`
message: `Unable to validate connection: verify credentials`
});
}
}

View File

@@ -86,7 +86,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa
};
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection - verify credentials`
message: `Unable to validate connection: verify credentials`
});
}
};

View File

@@ -49,7 +49,7 @@ export const ValidateDatabricksConnectionCredentialsSchema = z.discriminatedUnio
z.object({
method: z
.literal(DatabricksConnectionMethod.ServicePrincipal)
.describe(AppConnections?.CREATE(AppConnection.Databricks).method),
.describe(AppConnections.CREATE(AppConnection.Databricks).method),
credentials: DatabricksConnectionServicePrincipalInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Databricks).credentials
)

View File

@@ -4,10 +4,10 @@ import { GetAccessTokenResponse } from "google-auth-library/build/src/auth/oauth
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { AppConnection } from "../app-connection-enums";
import { getAppConnectionMethodName } from "../app-connection-fns";
import { GcpConnectionMethod } from "./gcp-connection-enums";
import {
GCPApp,

View File

@@ -37,7 +37,7 @@ export const ValidateGcpConnectionCredentialsSchema = z.discriminatedUnion("meth
z.object({
method: z
.literal(GcpConnectionMethod.ServiceAccountImpersonation)
.describe(AppConnections?.CREATE(AppConnection.GCP).method),
.describe(AppConnections.CREATE(AppConnection.GCP).method),
credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GCP).credentials
)

View File

@@ -200,7 +200,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
});
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection - verify credentials`
message: `Unable to validate connection: verify credentials`
});
}

View File

@@ -1,3 +1,3 @@
export enum HumanitecConnectionMethod {
API_TOKEN = "api-token"
ApiToken = "api-token"
}

View File

@@ -18,7 +18,7 @@ export const getHumanitecConnectionListItem = () => {
return {
name: "Humanitec" as const,
app: AppConnection.Humanitec as const,
methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.API_TOKEN]
methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.ApiToken]
};
};
@@ -40,7 +40,7 @@ export const validateHumanitecConnectionCredentials = async (config: THumanitecC
});
}
throw new BadRequestError({
message: "Unable to validate connection - verify credentials"
message: "Unable to validate connection: verify credentials"
});
}

View File

@@ -17,13 +17,13 @@ export const HumanitecConnectionAccessTokenCredentialsSchema = z.object({
const BaseHumanitecConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Humanitec) });
export const HumanitecConnectionSchema = BaseHumanitecConnectionSchema.extend({
method: z.literal(HumanitecConnectionMethod.API_TOKEN),
method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: HumanitecConnectionAccessTokenCredentialsSchema
});
export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", [
BaseHumanitecConnectionSchema.extend({
method: z.literal(HumanitecConnectionMethod.API_TOKEN),
method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: HumanitecConnectionAccessTokenCredentialsSchema.pick({})
})
]);
@@ -31,8 +31,8 @@ export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method",
export const ValidateHumanitecConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(HumanitecConnectionMethod.API_TOKEN)
.describe(AppConnections?.CREATE(AppConnection.Humanitec).method),
.literal(HumanitecConnectionMethod.ApiToken)
.describe(AppConnections.CREATE(AppConnection.Humanitec).method),
credentials: HumanitecConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Humanitec).credentials
)

View File

@@ -0,0 +1,4 @@
export * from "./mssql-connection-enums";
export * from "./mssql-connection-fns";
export * from "./mssql-connection-schemas";
export * from "./mssql-connection-types";

View File

@@ -0,0 +1,3 @@
export enum MsSqlConnectionMethod {
UsernameAndPassword = "username-and-password"
}

View File

@@ -0,0 +1,12 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { MsSqlConnectionMethod } from "./mssql-connection-enums";
export const getMsSqlConnectionListItem = () => {
return {
name: "Microsoft SQL Server" as const,
app: AppConnection.MsSql as const,
methods: Object.values(MsSqlConnectionMethod) as [MsSqlConnectionMethod.UsernameAndPassword],
supportsPlatformManagement: true as const
};
};

View File

@@ -0,0 +1,65 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { AppConnection } from "../app-connection-enums";
import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql";
import { MsSqlConnectionMethod } from "./mssql-connection-enums";
export const MsSqlConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema;
const BaseMsSqlConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.MsSql)
});
export const MsSqlConnectionSchema = BaseMsSqlConnectionSchema.extend({
method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
credentials: MsSqlConnectionAccessTokenCredentialsSchema
});
export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [
BaseMsSqlConnectionSchema.extend({
method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
credentials: MsSqlConnectionAccessTokenCredentialsSchema.pick({
host: true,
database: true,
port: true,
username: true
})
})
]);
export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(MsSqlConnectionMethod.UsernameAndPassword)
.describe(AppConnections.CREATE(AppConnection.MsSql).method),
credentials: MsSqlConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.MsSql).credentials
)
})
]);
export const CreateMsSqlConnectionSchema = ValidateMsSqlConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true })
);
export const UpdateMsSqlConnectionSchema = z
.object({
credentials: MsSqlConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.MsSql).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true }));
export const MsSqlConnectionListItemSchema = z.object({
name: z.literal("Microsoft SQL Server"),
app: z.literal(AppConnection.MsSql),
methods: z.nativeEnum(MsSqlConnectionMethod).array(),
supportsPlatformManagement: z.literal(true)
});

View File

@@ -0,0 +1,16 @@
import z from "zod";
import { AppConnection } from "../app-connection-enums";
import {
CreateMsSqlConnectionSchema,
MsSqlConnectionSchema,
ValidateMsSqlConnectionCredentialsSchema
} from "./mssql-connection-schemas";
export type TMsSqlConnection = z.infer<typeof MsSqlConnectionSchema>;
export type TMsSqlConnectionInput = z.infer<typeof CreateMsSqlConnectionSchema> & {
app: AppConnection.MsSql;
};
export type TValidateMsSqlConnectionCredentials = typeof ValidateMsSqlConnectionCredentialsSchema;

View File

@@ -0,0 +1,4 @@
export * from "./postgres-connection-enums";
export * from "./postgres-connection-fns";
export * from "./postgres-connection-schemas";
export * from "./postgres-connection-types";

View File

@@ -0,0 +1,3 @@
export enum PostgresConnectionMethod {
UsernameAndPassword = "username-and-password"
}

View File

@@ -0,0 +1,12 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { PostgresConnectionMethod } from "./postgres-connection-enums";
export const getPostgresConnectionListItem = () => {
return {
name: "PostgreSQL" as const,
app: AppConnection.Postgres as const,
methods: Object.values(PostgresConnectionMethod) as [PostgresConnectionMethod.UsernameAndPassword],
supportsPlatformManagement: true as const
};
};

View File

@@ -0,0 +1,63 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { AppConnection } from "../app-connection-enums";
import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql";
import { PostgresConnectionMethod } from "./postgres-connection-enums";
export const PostgresConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema;
const BasePostgresConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Postgres) });
export const PostgresConnectionSchema = BasePostgresConnectionSchema.extend({
method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
credentials: PostgresConnectionAccessTokenCredentialsSchema
});
export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", [
BasePostgresConnectionSchema.extend({
method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
credentials: PostgresConnectionAccessTokenCredentialsSchema.pick({
host: true,
database: true,
port: true,
username: true
})
})
]);
export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(PostgresConnectionMethod.UsernameAndPassword)
.describe(AppConnections.CREATE(AppConnection.Postgres).method),
credentials: PostgresConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Postgres).credentials
)
})
]);
export const CreatePostgresConnectionSchema = ValidatePostgresConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true })
);
export const UpdatePostgresConnectionSchema = z
.object({
credentials: PostgresConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Postgres).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true }));
export const PostgresConnectionListItemSchema = z.object({
name: z.literal("PostgreSQL"),
app: z.literal(AppConnection.Postgres),
methods: z.nativeEnum(PostgresConnectionMethod).array(),
supportsPlatformManagement: z.literal(true)
});

View File

@@ -0,0 +1,16 @@
import z from "zod";
import { AppConnection } from "../app-connection-enums";
import {
CreatePostgresConnectionSchema,
PostgresConnectionSchema,
ValidatePostgresConnectionCredentialsSchema
} from "./postgres-connection-schemas";
export type TPostgresConnection = z.infer<typeof PostgresConnectionSchema>;
export type TPostgresConnectionInput = z.infer<typeof CreatePostgresConnectionSchema> & {
app: AppConnection.Postgres;
};
export type TValidatePostgresConnectionCredentials = typeof ValidatePostgresConnectionCredentialsSchema;

View File

@@ -0,0 +1,2 @@
export * from "./sql-connection-fns";
export * from "./sql-connection-schemas";

View File

@@ -0,0 +1,113 @@
import knex, { Knex } from "knex";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import {
TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationWithConnection
} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types";
import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
const SQL_CONNECTION_CLIENT_MAP = {
[AppConnection.Postgres]: "pg",
[AppConnection.MsSql]: "mssql"
};
export const getSqlConnectionClient = async (
appConnection: Pick<TSqlConnection, "credentials" | "app">,
options?: Record<string, unknown>
) => {
const {
app,
credentials: { host: baseHost, database, port, sslCertificate, password, username }
} = appConnection;
const ssl = sslCertificate ? { rejectUnauthorized: false, ca: sslCertificate } : undefined;
const [host] = await verifyHostInputValidity(baseHost);
const client = knex({
client: SQL_CONNECTION_CLIENT_MAP[app],
connection: {
database,
port,
host,
user: username,
password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
ssl,
options
}
});
return client;
};
export const validateSqlConnectionCredentials = async (config: TSqlConnectionConfig) => {
const { credentials, app } = config;
const client = await getSqlConnectionClient({ app, credentials });
try {
await client.raw(`Select 1`);
return credentials;
} catch (error) {
throw new BadRequestError({
message:
(error as Error)?.message?.replaceAll(credentials.password, "********************") ??
"Unable to validate connection: verify credentials"
});
} finally {
await client.destroy();
}
};
export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record<
TSqlCredentialsRotationWithConnection["connection"]["app"],
(credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => [string, Knex.RawBinding]
> = {
[AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]],
[AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]]
};
export const transferSqlConnectionCredentialsToPlatform = async (
config: TSqlConnectionConfig,
callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise<TAppConnectionRaw>
) => {
const { credentials, app } = config;
const client = await getSqlConnectionClient({ app, credentials });
const newPassword = alphaNumericNanoId(32);
try {
return await client.transaction(async (tx) => {
await tx.raw(
...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword })
);
return callback({
...credentials,
password: newPassword
});
});
} catch (error) {
// update/create service function will handle
if (error instanceof DatabaseError) {
throw error;
}
throw new BadRequestError({
message:
(error as Error)?.message?.replaceAll(newPassword, "********************") ??
"Encountered an error transferring credentials to platform"
});
} finally {
await client.destroy();
}
};

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({
host: z.string().trim().min(1, "Host required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.host),
port: z.coerce.number().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.port),
database: z.string().trim().min(1, "Database required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.database),
username: z.string().trim().min(1, "Username required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.username),
password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.password),
sslCertificate: z.string().trim().optional().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslCertificate)
});

View File

@@ -0,0 +1,6 @@
import { DiscriminativePick } from "@app/lib/types";
import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types";
export type TSqlConnectionConfig = DiscriminativePick<TSqlConnectionInput, "method" | "app" | "credentials"> & {
orgId: string;
};

View File

@@ -31,7 +31,11 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre
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")
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
db
.ref("isPlatformManagedCredentials")
.withSchema(TableName.AppConnection)
.as("connectionIsPlatformManagedCredentials")
);
if (filter) {
@@ -60,6 +64,7 @@ const expandSecretSync = (
connectionCreatedAt,
connectionUpdatedAt,
connectionVersion,
connectionIsPlatformManagedCredentials,
...el
} = secretSync;
@@ -77,7 +82,8 @@ const expandSecretSync = (
description: connectionDescription,
createdAt: connectionCreatedAt,
updatedAt: connectionUpdatedAt,
version: connectionVersion
version: connectionVersion,
isPlatformManagedCredentials: connectionIsPlatformManagedCredentials
},
folder: folder
? {

View File

@@ -119,14 +119,10 @@ export const secretSyncServiceFactory = ({
{ 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({
// we prevent conflicting names within a project
const secretSync = await secretSyncDAL.findOne({
name: syncName,
$in: {
folderId: folders.map((folder) => folder.id)
}
projectId
});
if (!secretSync)

View File

@@ -1,6 +1,6 @@
import { Job } from "bullmq";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import {
@@ -129,8 +129,6 @@ export type TDeleteSecretSyncDTO = {
removeSecrets: boolean;
};
type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">;
export enum SecretSyncStatus {
Pending = "pending",
Running = "running",

View File

@@ -35,15 +35,25 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.leftJoin(
TableName.SecretRotationV2SecretMapping,
`${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId`
)
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"));
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
const data = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
parentMapper: (el) => ({
_id: el.id,
...SecretsV2Schema.parse(el),
isRotatedSecret: Boolean(el.rotationId),
rotationId: el.rotationId
}),
childrenMapper: [
{
key: "tagId",
@@ -79,6 +89,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.leftJoin(
TableName.SecretRotationV2SecretMapping,
`${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId`
)
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
@@ -87,7 +102,8 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"));
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
@@ -98,7 +114,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
const data = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
parentMapper: (el) => ({
_id: el.id,
...SecretsV2Schema.parse(el),
rotationId: el.rotationId,
isRotatedSecret: Boolean(el.rotationId)
}),
childrenMapper: [
{
key: "tagId",
@@ -332,6 +353,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
}
const query = (tx || db.replicaNode())(TableName.SecretV2)
.leftJoin(
TableName.SecretRotationV2SecretMapping,
`${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId`
)
.whereIn("folderId", folderIds)
.where((bd) => {
if (filters?.search) {
@@ -414,6 +440,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.leftJoin(
TableName.SecretRotationV2SecretMapping,
`${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId`
)
.where((qb) => {
if (filters?.metadataFilter && filters.metadataFilter.length > 0) {
filters.metadataFilter.forEach((meta) => {
@@ -444,6 +475,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping))
.where((bd) => {
const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) {
@@ -472,7 +504,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
const data = sqlNestRelationships({
data: secs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
parentMapper: (el) => ({
_id: el.id,
...SecretsV2Schema.parse(el),
rotationId: el.rotationId,
isRotatedSecret: Boolean(el.rotationId)
}),
childrenMapper: [
{
key: "tagId",

View File

@@ -666,6 +666,8 @@ export const reshapeBridgeSecret = (
name: string;
}[];
secretMetadata?: ResourceMetadataDTO;
isRotatedSecret?: boolean;
rotationId?: string;
},
secretValueHidden: boolean
) => ({
@@ -695,7 +697,8 @@ export const reshapeBridgeSecret = (
secretMetadata: secret.secretMetadata,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt,
isRotatedSecret: secret.isRotatedSecret,
rotationId: secret.rotationId,
...(secretValueHidden
? {
secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK,

View File

@@ -25,6 +25,7 @@ import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-app
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { diff, groupBy } from "@app/lib/fn";
import { setKnexStringValue } from "@app/lib/knex";
@@ -414,6 +415,8 @@ export const secretV2BridgeServiceFactory = ({
});
if (!sharedSecretToModify)
throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` });
if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue))
throw new BadRequestError({ message: "Cannot update rotated secret name or value" });
secretId = sharedSecretToModify.id;
secret = sharedSecretToModify;
}
@@ -624,66 +627,79 @@ export const secretV2BridgeServiceFactory = ({
})
);
const deletedSecret = await secretDAL.transaction(async (tx) =>
fnSecretBulkDelete({
projectId,
folderId,
actorId,
secretDAL,
secretQueueService,
inputSecrets: [
{
type: inputSecret.type as SecretType,
secretKey: inputSecret.secretName
}
],
tx
})
);
try {
const deletedSecret = await secretDAL.transaction(async (tx) =>
fnSecretBulkDelete({
projectId,
folderId,
actorId,
secretDAL,
secretQueueService,
inputSecrets: [
{
type: inputSecret.type as SecretType,
secretKey: inputSecret.secretName
}
],
tx
})
);
if (inputSecret.type === SecretType.Shared) {
await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({
secretPath,
actorId,
actor,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
if (inputSecret.type === SecretType.Shared) {
await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({
secretPath,
actorId,
actor,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
});
}
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
}
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.ReadValue,
{
environment,
secretPath,
secretName: secretToDelete.key,
secretTags: secretToDelete.tags?.map((el) => el.slug)
}
);
const secretValueHidden = !hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.ReadValue,
{
return reshapeBridgeSecret(
projectId,
environment,
secretPath,
secretName: secretToDelete.key,
secretTags: secretToDelete.tags?.map((el) => el.slug)
{
...deletedSecret[0],
value: deletedSecret[0].encryptedValue
? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString()
: "",
comment: deletedSecret[0].encryptedComment
? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString()
: ""
},
secretValueHidden
);
} catch (err) {
// deferred errors aren't return as DatabaseError
const error = err as { code: string; table: string };
if (
error?.code === DatabaseErrorCode.ForeignKeyViolation &&
error?.table === TableName.SecretRotationV2SecretMapping
) {
throw new BadRequestError({ message: "Cannot delete rotated secrets" });
}
);
return reshapeBridgeSecret(
projectId,
environment,
secretPath,
{
...deletedSecret[0],
value: deletedSecret[0].encryptedValue
? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString()
: "",
comment: deletedSecret[0].encryptedComment
? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString()
: ""
},
secretValueHidden
);
throw err;
}
};
// get unique secrets count for multiple envs
@@ -946,6 +962,7 @@ export const secretV2BridgeServiceFactory = ({
projectId
});
// scott: if any of this changes it also needs to be mirrored in secret rotation for getting dashboard secrets
const decryptedSecrets = secrets
.filter((el) => {
const canDescribeSecret = hasSecretReadValueOrDescribePermission(
@@ -1667,6 +1684,13 @@ export const secretV2BridgeServiceFactory = ({
secretTags: el.tags.map((i) => i.slug)
})
);
if (el.isRotatedSecret) {
const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key);
if (input && (input.newSecretName || input.secretValue))
throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` });
}
});
// get all tags
@@ -1969,61 +1993,76 @@ export const secretV2BridgeServiceFactory = ({
);
});
const secretsDeleted = await secretDAL.transaction(async (tx) =>
fnSecretBulkDelete({
secretDAL,
secretQueueService,
inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
secretKey,
type: type || SecretType.Shared
})),
projectId,
folderId,
actorId,
tx
})
);
// await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({
actor,
actorId,
secretPath,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
});
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
return secretsDeleted.map((el) => {
const secretToDeleteMatch = secretsToDelete.find(
(i) => i.key === el.key && (i.type || SecretType.Shared) === el.type
try {
const secretsDeleted = await secretDAL.transaction(async (tx) =>
fnSecretBulkDelete({
secretDAL,
secretQueueService,
inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
secretKey,
type: type || SecretType.Shared
})),
projectId,
folderId,
actorId,
tx
})
);
const secretValueHidden =
!secretToDeleteMatch ||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({
actor,
actorId,
secretPath,
projectId,
orgId: actorOrgId,
environmentSlug: folder.environment.slug
});
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
return secretsDeleted.map((el) => {
const secretToDeleteMatch = secretsToDelete.find(
(i) => i.key === el.key && (i.type || SecretType.Shared) === el.type
);
const secretValueHidden =
!secretToDeleteMatch ||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
environment,
secretPath,
secretName: el.key,
secretTags: secretToDeleteMatch.tags?.map((i) => i.slug)
});
return reshapeBridgeSecret(
projectId,
environment,
secretPath,
secretName: el.key,
secretTags: secretToDeleteMatch.tags?.map((i) => i.slug)
});
{
...el,
value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
comment: el.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString()
: ""
},
secretValueHidden
);
});
} catch (err) {
// deferred errors aren't return as DatabaseError
const error = err as { code: string; table: string };
if (
error?.code === DatabaseErrorCode.ForeignKeyViolation &&
error?.table === TableName.SecretRotationV2SecretMapping
) {
throw new BadRequestError({ message: "Cannot delete rotated secrets" });
}
return reshapeBridgeSecret(
projectId,
environment,
secretPath,
{
...el,
value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : ""
},
secretValueHidden
);
});
throw err;
}
};
const getSecretVersions = async ({

View File

@@ -132,7 +132,7 @@ export type TUpdateManySecretDTO = Omit<TProjectPermission, "projectId"> & {
secrets: {
secretKey: string;
newSecretName?: string;
secretValue: string;
secretValue?: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
tagIds?: string[];

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