feat(infisical-pg): completed secret rollback

This commit is contained in:
Akhil Mohan
2024-01-07 18:25:24 +05:30
parent 784cdb4201
commit b5aa6c0000
57 changed files with 2017 additions and 453 deletions

View File

@@ -5,6 +5,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
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 { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service";
import { TAuthLoginFactory } from "@app/services/auth/auth-login-service";
import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service";
@@ -96,6 +97,7 @@ declare module "fastify" {
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
secretApprovalRequest: TSecretApprovalRequestServiceFactory;
secretRotation: TSecretRotationServiceFactory;
snapshot: TSecretSnapshotServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data

View File

@@ -91,6 +91,9 @@ import {
TSecretFolders,
TSecretFoldersInsert,
TSecretFoldersUpdate,
TSecretFolderVersions,
TSecretFolderVersionsInsert,
TSecretFolderVersionsUpdate,
TSecretImports,
TSecretImportsInsert,
TSecretImportsUpdate,
@@ -102,6 +105,15 @@ import {
TSecretRotationsUpdate,
TSecrets,
TSecretsInsert,
TSecretSnapshotFolders,
TSecretSnapshotFoldersInsert,
TSecretSnapshotFoldersUpdate,
TSecretSnapshots,
TSecretSnapshotSecrets,
TSecretSnapshotSecretsInsert,
TSecretSnapshotSecretsUpdate,
TSecretSnapshotsInsert,
TSecretSnapshotsUpdate,
TSecretsUpdate,
TSecretTagJunction,
TSecretTagJunctionInsert,
@@ -224,6 +236,11 @@ declare module "knex/types/tables" {
TSecretFoldersInsert,
TSecretFoldersUpdate
>;
[TableName.SecretFolderVersion]: Knex.CompositeTableType<
TSecretFolderVersions,
TSecretFolderVersionsInsert,
TSecretFolderVersionsUpdate
>;
[TableName.SecretTag]: Knex.CompositeTableType<
TSecretTags,
TSecretTagsInsert,
@@ -234,6 +251,11 @@ declare module "knex/types/tables" {
TSecretImportsInsert,
TSecretImportsUpdate
>;
[TableName.SecretSnapshot]: Knex.CompositeTableType<
TSecretSnapshots,
TSecretSnapshotsInsert,
TSecretSnapshotsUpdate
>;
[TableName.Integration]: Knex.CompositeTableType<
TIntegrations,
TIntegrationsInsert,
@@ -320,6 +342,21 @@ declare module "knex/types/tables" {
TSecretRotationOutputsInsert,
TSecretRotationOutputsUpdate
>;
[TableName.Snapshot]: Knex.CompositeTableType<
TSecretSnapshots,
TSecretSnapshotsInsert,
TSecretSnapshotsUpdate
>;
[TableName.SnapshotSecret]: Knex.CompositeTableType<
TSecretSnapshotSecrets,
TSecretSnapshotSecretsInsert,
TSecretSnapshotSecretsUpdate
>;
[TableName.SnapshotFolder]: Knex.CompositeTableType<
TSecretSnapshotFolders,
TSecretSnapshotFoldersInsert,
TSecretSnapshotFoldersUpdate
>;
// Junction tables
[TableName.JnSecretTag]: Knex.CompositeTableType<
TSecretTagJunction,

View File

@@ -17,9 +17,26 @@ export async function up(knex: Knex): Promise<void> {
});
}
await createOnUpdateTrigger(knex, TableName.SecretFolder);
if (!(await knex.schema.hasTable(TableName.SecretFolderVersion))) {
await knex.schema.createTable(TableName.SecretFolderVersion, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.integer("version").defaultTo(1);
t.timestamps(true, true, true);
t.uuid("envId").notNullable();
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
t.uuid("folderId").notNullable();
// t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("SET NULL");
});
}
await createOnUpdateTrigger(knex, TableName.SecretFolderVersion);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretFolderVersion);
await knex.schema.dropTableIfExists(TableName.SecretFolder);
await dropOnUpdateTrigger(knex, TableName.SecretFolder);
await dropOnUpdateTrigger(knex, TableName.SecretFolderVersion);
}

View File

@@ -37,7 +37,7 @@ export async function up(knex: Knex): Promise<void> {
t.text("secretCommentCiphertext");
t.text("secretCommentIV");
t.text("secretCommentTag");
t.string("secretReminderNotice");
t.string("secretReminderNote");
t.integer("secretReminderRepeatDays");
t.boolean("skipMultilineEncoding").defaultTo(false);
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);

View File

@@ -9,9 +9,6 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.integer("version").defaultTo(1);
t.string("type").notNullable().defaultTo(SecretType.Shared);
// t.text("secretKeyHash").notNullable();
// t.text("secretValueHash");
// t.text("secretCommentHash");
t.text("secretBlindIndex").notNullable();
t.text("secretKeyCiphertext").notNullable();
t.text("secretKeyIV").notNullable();
@@ -22,18 +19,20 @@ export async function up(knex: Knex): Promise<void> {
t.text("secretCommentCiphertext");
t.text("secretCommentIV");
t.text("secretCommentTag");
t.string("secretReminderNotice");
t.string("secretReminderNote");
t.integer("secretReminderRepeatDays");
t.boolean("skipMultilineEncoding").defaultTo(false);
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);
t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8);
t.jsonb("metadata");
t.uuid("secretId");
t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL");
// to avoid orphan rows
t.uuid("envId");
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
t.uuid("secretId").notNullable();
t.uuid("folderId").notNullable();
// t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL");
t.uuid("userId");
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.uuid("folderId").notNullable();
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}

View File

@@ -64,7 +64,7 @@ export async function up(knex: Knex): Promise<void> {
t.text("secretCommentCiphertext");
t.text("secretCommentIV");
t.text("secretCommentTag");
t.string("secretReminderNotice");
t.string("secretReminderNote");
t.integer("secretReminderRepeatDays");
t.boolean("skipMultilineEncoding").defaultTo(false);
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);
@@ -103,7 +103,7 @@ export async function up(knex: Knex): Promise<void> {
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretTag);
await knex.schema.dropTableIfExists(TableName.SarSecretTag);
await knex.schema.dropTableIfExists(TableName.SarSecret);
await knex.schema.dropTableIfExists(TableName.SarReviewer);
await knex.schema.dropTableIfExists(TableName.SecretApprovalRequest);

View File

@@ -0,0 +1,61 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.Snapshot))) {
await knex.schema.createTable(TableName.Snapshot, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("envId").notNullable();
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
// this is not a relation kept like that
// this ensure snapshot are not lost when folder gets deleted and rolled back
t.uuid("folderId").notNullable();
t.uuid("parentFolderId");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.Snapshot);
if (!(await knex.schema.hasTable(TableName.SnapshotSecret))) {
await knex.schema.createTable(TableName.SnapshotSecret, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("envId").notNullable();
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
// not a relation kept like that to keep it when rolled back
t.uuid("secretVersionId").notNullable();
t.foreign("secretVersionId")
.references("id")
.inTable(TableName.SecretVersion)
.onDelete("CASCADE");
t.uuid("snapshotId").notNullable();
t.foreign("snapshotId").references("id").inTable(TableName.Snapshot).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
if (!(await knex.schema.hasTable(TableName.SnapshotFolder))) {
await knex.schema.createTable(TableName.SnapshotFolder, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("envId").notNullable();
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
// not a relation kept like that to keep it when rolled back
t.uuid("folderVersionId").notNullable();
t.foreign("folderVersionId")
.references("id")
.inTable(TableName.SecretFolderVersion)
.onDelete("CASCADE");
t.uuid("snapshotId").notNullable();
t.foreign("snapshotId").references("id").inTable(TableName.Snapshot).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SnapshotSecret);
await knex.schema.dropTableIfExists(TableName.SnapshotFolder);
await knex.schema.dropTableIfExists(TableName.Snapshot);
await dropOnUpdateTrigger(knex, TableName.Snapshot);
}

View File

@@ -9,7 +9,7 @@ import { TImmutableDBKeys } from "./models";
export const IdentityUaClientSecretsSchema = z.object({
id: z.string().uuid(),
description: z.string().nullable().optional(),
description: z.string(),
clientSecretPrefix: z.string(),
clientSecretHash: z.string(),
clientSecretLastUsedAt: z.date().nullable().optional(),
@@ -19,11 +19,9 @@ export const IdentityUaClientSecretsSchema = z.object({
isClientSecretRevoked: z.boolean().default(false),
createdAt: z.date(),
updatedAt: z.date(),
identityUAId: z.string().uuid()
identityUAId: z.string().uuid(),
});
export type TIdentityUaClientSecrets = z.infer<typeof IdentityUaClientSecretsSchema>;
export type TIdentityUaClientSecretsInsert = Omit<TIdentityUaClientSecrets, TImmutableDBKeys>;
export type TIdentityUaClientSecretsUpdate = Partial<
Omit<TIdentityUaClientSecrets, TImmutableDBKeys>
>;
export type TIdentityUaClientSecretsUpdate = Partial<Omit<TIdentityUaClientSecrets, TImmutableDBKeys>>;

View File

@@ -17,11 +17,9 @@ export const IdentityUniversalAuthsSchema = z.object({
accessTokenTrustedIps: z.unknown(),
createdAt: z.date(),
updatedAt: z.date(),
identityId: z.string().uuid()
identityId: z.string().uuid(),
});
export type TIdentityUniversalAuths = z.infer<typeof IdentityUniversalAuthsSchema>;
export type TIdentityUniversalAuthsInsert = Omit<TIdentityUniversalAuths, TImmutableDBKeys>;
export type TIdentityUniversalAuthsUpdate = Partial<
Omit<TIdentityUniversalAuths, TImmutableDBKeys>
>;
export type TIdentityUniversalAuthsUpdate = Partial<Omit<TIdentityUniversalAuths, TImmutableDBKeys>>;

View File

@@ -28,10 +28,14 @@ export * from "./sar-reviewers";
export * from "./secret-approval-policies";
export * from "./secret-approval-requests";
export * from "./secret-blind-indexes";
export * from "./secret-folder-versions";
export * from "./secret-folders";
export * from "./secret-imports";
export * from "./secret-rotation-outputs";
export * from "./secret-rotations";
export * from "./secret-snapshot-folders";
export * from "./secret-snapshot-secrets";
export * from "./secret-snapshots";
export * from "./secret-tag-junction";
export * from "./secret-tags";
export * from "./secret-versions";

View File

@@ -0,0 +1,17 @@
// 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 KnexMigrationsLockSchema = z.object({
index: z.number(),
is_locked: z.number().nullable().optional(),
});
export type TKnexMigrationsLock = z.infer<typeof KnexMigrationsLockSchema>;
export type TKnexMigrationsLockInsert = Omit<TKnexMigrationsLock, TImmutableDBKeys>;
export type TKnexMigrationsLockUpdate = Partial<Omit<TKnexMigrationsLock, TImmutableDBKeys>>;

View File

@@ -0,0 +1,19 @@
// 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 KnexMigrationsSchema = z.object({
id: z.number(),
name: z.string().nullable().optional(),
batch: z.number().nullable().optional(),
migration_time: z.date().nullable().optional(),
});
export type TKnexMigrations = z.infer<typeof KnexMigrationsSchema>;
export type TKnexMigrationsInsert = Omit<TKnexMigrations, TImmutableDBKeys>;
export type TKnexMigrationsUpdate = Partial<Omit<TKnexMigrations, TImmutableDBKeys>>;

View File

@@ -23,7 +23,11 @@ export enum TableName {
SecretBlindIndex = "secret_blind_indexes",
SecretVersion = "secret_versions",
SecretFolder = "secret_folders",
SecretFolderVersion = "secret_folder_versions",
SecretImport = "secret_imports",
Snapshot = "secret_snapshots",
SnapshotSecret = "secret_snapshot_secrets",
SnapshotFolder = "secret_snapshot_folders",
SecretTag = "secret_tags",
Integration = "integrations",
IntegrationAuth = "integration_auths",

View File

@@ -17,12 +17,12 @@ export const ProjectBotsSchema = z.object({
tag: z.string(),
algorithm: z.string(),
keyEncoding: z.string(),
encryptedProjectKey: z.string().optional().nullable(),
encryptedProjectKeyNonce: z.string().optional().nullable(),
encryptedProjectKey: z.string().nullable().optional(),
encryptedProjectKeyNonce: z.string().nullable().optional(),
projectId: z.string().uuid(),
senderId: z.string().uuid().optional().nullable(),
senderId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
});
export type TProjectBots = z.infer<typeof ProjectBotsSchema>;

View File

@@ -12,7 +12,7 @@ export const ProjectKeysSchema = z.object({
encryptedKey: z.string(),
nonce: z.string(),
receiverId: z.string().uuid(),
senderId: z.string().uuid(),
senderId: z.string().uuid().nullable().optional(),
projectId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),

View File

@@ -20,7 +20,7 @@ export const SaRequestSecretsSchema = z.object({
secretCommentCiphertext: z.string().nullable().optional(),
secretCommentIV: z.string().nullable().optional(),
secretCommentTag: z.string().nullable().optional(),
secretReminderNotice: z.string().nullable().optional(),
secretReminderNote: z.string().nullable().optional(),
secretReminderRepeatDays: z.number().nullable().optional(),
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
algorithm: z.string().default("aes-256-gcm"),

View File

@@ -11,18 +11,16 @@ export const SecretApprovalRequestsSchema = z.object({
id: z.string().uuid(),
policyId: z.string().uuid(),
hasMerged: z.boolean().default(false),
status: z.string().default("open"),
status: z.string().default('open'),
conflicts: z.unknown().nullable().optional(),
slug: z.string(),
folderId: z.string().uuid(),
statusChangeBy: z.string().uuid().optional().nullable(),
statusChangeBy: z.string().uuid().nullable().optional(),
committerId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
});
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;
export type TSecretApprovalRequestsInsert = Omit<TSecretApprovalRequests, TImmutableDBKeys>;
export type TSecretApprovalRequestsUpdate = Partial<
Omit<TSecretApprovalRequests, TImmutableDBKeys>
>;
export type TSecretApprovalRequestsUpdate = Partial<Omit<TSecretApprovalRequests, TImmutableDBKeys>>;

View File

@@ -0,0 +1,22 @@
// 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 SecretFolderVersionsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
version: z.number().default(1).nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
envId: z.string().uuid(),
folderId: z.string().uuid(),
});
export type TSecretFolderVersions = z.infer<typeof SecretFolderVersionsSchema>;
export type TSecretFolderVersionsInsert = Omit<TSecretFolderVersions, TImmutableDBKeys>;
export type TSecretFolderVersionsUpdate = Partial<Omit<TSecretFolderVersions, TImmutableDBKeys>>;

View File

@@ -0,0 +1,21 @@
// 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 SecretSnapshotFoldersSchema = z.object({
id: z.string().uuid(),
envId: z.string().uuid(),
folderVersionId: z.string().uuid(),
snapshotId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TSecretSnapshotFolders = z.infer<typeof SecretSnapshotFoldersSchema>;
export type TSecretSnapshotFoldersInsert = Omit<TSecretSnapshotFolders, TImmutableDBKeys>;
export type TSecretSnapshotFoldersUpdate = Partial<Omit<TSecretSnapshotFolders, TImmutableDBKeys>>;

View File

@@ -0,0 +1,21 @@
// 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 SecretSnapshotSecretsSchema = z.object({
id: z.string().uuid(),
envId: z.string().uuid(),
secretVersionId: z.string().uuid(),
snapshotId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TSecretSnapshotSecrets = z.infer<typeof SecretSnapshotSecretsSchema>;
export type TSecretSnapshotSecretsInsert = Omit<TSecretSnapshotSecrets, TImmutableDBKeys>;
export type TSecretSnapshotSecretsUpdate = Partial<Omit<TSecretSnapshotSecrets, TImmutableDBKeys>>;

View File

@@ -0,0 +1,21 @@
// 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 SecretSnapshotsSchema = z.object({
id: z.string().uuid(),
envId: z.string().uuid(),
folderId: z.string().uuid(),
parentFolderId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TSecretSnapshots = z.infer<typeof SecretSnapshotsSchema>;
export type TSecretSnapshotsInsert = Omit<TSecretSnapshots, TImmutableDBKeys>;
export type TSecretSnapshotsUpdate = Partial<Omit<TSecretSnapshots, TImmutableDBKeys>>;

View File

@@ -0,0 +1,18 @@
// 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 SecretVersionTagJunctionSchema = z.object({
id: z.string().uuid(),
secret_versionsId: z.string().uuid(),
secret_tagsId: z.string().uuid(),
});
export type TSecretVersionTagJunction = z.infer<typeof SecretVersionTagJunctionSchema>;
export type TSecretVersionTagJunctionInsert = Omit<TSecretVersionTagJunction, TImmutableDBKeys>;
export type TSecretVersionTagJunctionUpdate = Partial<Omit<TSecretVersionTagJunction, TImmutableDBKeys>>;

View File

@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const SecretVersionsSchema = z.object({
id: z.string().uuid(),
version: z.number().default(1).nullable().optional(),
type: z.string().default('shared'),
type: z.string().default("shared"),
secretBlindIndex: z.string(),
secretKeyCiphertext: z.string(),
secretKeyIV: z.string(),
@@ -21,17 +21,18 @@ export const SecretVersionsSchema = z.object({
secretCommentCiphertext: z.string().nullable().optional(),
secretCommentIV: z.string().nullable().optional(),
secretCommentTag: z.string().nullable().optional(),
secretReminderNotice: z.string().nullable().optional(),
secretReminderNote: z.string().nullable().optional(),
secretReminderRepeatDays: z.number().nullable().optional(),
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
algorithm: z.string().default('aes-256-gcm'),
keyEncoding: z.string().default('utf8'),
algorithm: z.string().default("aes-256-gcm"),
keyEncoding: z.string().default("utf8"),
metadata: z.unknown().nullable().optional(),
envId: z.string().uuid().nullable().optional(),
secretId: z.string().uuid(),
userId: z.string().uuid().nullable().optional(),
folderId: z.string().uuid(),
userId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
updatedAt: z.date()
});
export type TSecretVersions = z.infer<typeof SecretVersionsSchema>;

View File

@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const SecretsSchema = z.object({
id: z.string().uuid(),
version: z.number().default(1).nullable().optional(),
type: z.string().default('shared'),
type: z.string().default("shared"),
secretBlindIndex: z.string(),
secretKeyCiphertext: z.string(),
secretKeyIV: z.string(),
@@ -21,16 +21,16 @@ export const SecretsSchema = z.object({
secretCommentCiphertext: z.string().nullable().optional(),
secretCommentIV: z.string().nullable().optional(),
secretCommentTag: z.string().nullable().optional(),
secretReminderNotice: z.string().nullable().optional(),
secretReminderNote: z.string().nullable().optional(),
secretReminderRepeatDays: z.number().nullable().optional(),
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
algorithm: z.string().default('aes-256-gcm'),
keyEncoding: z.string().default('utf8'),
algorithm: z.string().default("aes-256-gcm"),
keyEncoding: z.string().default("utf8"),
metadata: z.unknown().nullable().optional(),
userId: z.string().uuid().nullable().optional(),
folderId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
updatedAt: z.date()
});
export type TSecrets = z.infer<typeof SecretsSchema>;

View File

@@ -1,14 +1,23 @@
import { registerOrgRoleRouter } from "./org-role-router";
import { registerProjectRoleRouter } from "./project-role-router";
import { registerProjectRouter } from "./project-router";
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router";
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
import { registerSecretRotationRouter } from "./secret-rotation-router";
import { registerSnapshotRouter } from "./snapshot-router";
export const registerV1EERoutes = async (server: FastifyZodProvider) => {
// org role starts with organization
await server.register(registerOrgRoleRouter, { prefix: "/organization" });
await server.register(registerProjectRoleRouter, { prefix: "/workspace" });
await server.register(
async (projectServer) => {
projectServer.register(registerProjectRoleRouter);
projectServer.register(registerProjectRouter);
},
{ prefix: "/workspace" }
);
await server.register(registerSnapshotRouter, { prefix: "/secret-snapshot" });
await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" });
await server.register(registerSecretApprovalRequestRouter, {
prefix: "/secret-approval-requests"

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import { SecretSnapshotsSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:workspaceId/secret-snapshots",
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
querystring: z.object({
environment: z.string().trim(),
path: z.string().trim().default("/"),
offset: z.coerce.number().default(0),
limit: z.coerce.number().default(20)
}),
response: {
200: z.object({
secretSnapshots: SecretSnapshotsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretSnapshots = await server.services.snapshot.listSnapshots({
actor: req.permission.type,
actorId: req.permission.id,
projectId: req.params.workspaceId,
...req.query
});
return { secretSnapshots };
}
});
server.route({
method: "GET",
url: "/:workspaceId/secret-snapshots/count",
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
querystring: z.object({
environment: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
count: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const count = await server.services.snapshot.projectSecretSnapshotCount({
actor: req.permission.type,
actorId: req.permission.id,
projectId: req.params.workspaceId,
environment: req.query.environment,
path: req.query.path
});
return { count };
}
});
};

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import { SecretSnapshotsSchema, SecretVersionsSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSnapshotRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:secretSnapshotId",
schema: {
params: z.object({
secretSnapshotId: z.string().trim()
}),
response: {
200: z.object({
secretSnapshot: z.object({
id: z.string().uuid(),
projectId: z.string().uuid(),
environment: z.object({
id: z.string().uuid(),
slug: z.string(),
name: z.string()
}),
secretVersions: SecretVersionsSchema.omit({ secretBlindIndex: true }).array(),
folderVersion: z.object({ id: z.string(), name: z.string() }).array(),
createdAt: z.date(),
updatedAt: z.date()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretSnapshot = await server.services.snapshot.getSnapshotData({
actor: req.permission.type,
actorId: req.permission.id,
id: req.params.secretSnapshotId
});
return { secretSnapshot };
}
});
server.route({
method: "POST",
url: "/:secretSnapshotId/rollback",
schema: {
params: z.object({
secretSnapshotId: z.string().trim()
}),
response: {
200: z.object({
secretSnapshot: SecretSnapshotsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretSnapshot = await server.services.snapshot.rollbackSnapshot({
actor: req.permission.type,
actorId: req.permission.id,
id: req.params.secretSnapshotId
});
return { secretSnapshot };
}
});
};

View File

@@ -134,7 +134,7 @@ export const secretApprovalPolicyServiceFactory = ({
projectId: secretApprovalPolicy.projectId,
$in: { id: approvers }
},
tx
{ tx }
);
if (secretApprovers.length !== approvers.length)
throw new BadRequestError({ message: "Approver not found in project" });

View File

@@ -301,7 +301,7 @@ export const secretApprovalRequestServiceFactory = ({
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretReminderNotice,
secretReminderNote,
secretReminderRepeatDays
}) => ({
secretBlindIndex,
@@ -316,7 +316,7 @@ export const secretApprovalRequestServiceFactory = ({
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretReminderNotice,
secretReminderNote,
secretReminderRepeatDays,
version: 1,
folderId,
@@ -346,7 +346,7 @@ export const secretApprovalRequestServiceFactory = ({
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretReminderNotice,
secretReminderNote,
secretReminderRepeatDays
}) => ({
folderId,
@@ -365,7 +365,7 @@ export const secretApprovalRequestServiceFactory = ({
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretReminderNotice,
secretReminderNote,
secretReminderRepeatDays
})
),
@@ -661,7 +661,7 @@ export const secretApprovalRequestServiceFactory = ({
secretCommentTag,
secretKeyCiphertext,
secretValueCiphertext,
secretReminderNotice,
secretReminderNote,
skipMultilineEncoding,
secretCommentCiphertext,
secretReminderRepeatDays
@@ -683,7 +683,7 @@ export const secretApprovalRequestServiceFactory = ({
secretCommentTag,
secretKeyCiphertext,
secretValueCiphertext,
secretReminderNotice,
secretReminderNote,
skipMultilineEncoding,
secretCommentCiphertext,
secretReminderRepeatDays

View File

@@ -20,7 +20,8 @@ import {
secretRotationDbFn,
secretRotationHttpFn,
secretRotationHttpSetFn,
secretRotationPreSetFn} from "./secret-rotation-queue-fn";
secretRotationPreSetFn
} from "./secret-rotation-queue-fn";
import {
TSecretRotationData,
TSecretRotationDbFn,
@@ -34,7 +35,7 @@ type TSecretRotationQueueFactoryDep = {
secretRotationDal: TSecretRotationDalFactory;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
secretDal: Pick<TSecretDalFactory, "bulkUpdate">;
secretVersionDal: Pick<TSecretVersionDalFactory, "insertMany">;
secretVersionDal: Pick<TSecretVersionDalFactory, "insertMany" | "findLatestVersionMany">;
};
// These error should stop the repeatable job and ask user to reconfigure rotation

View File

@@ -0,0 +1,264 @@
import { ForbiddenError } from "@casl/ability";
import { BadRequestError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { TSecretDalFactory } from "@app/services/secret/secret-dal";
import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal";
import { TSecretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretFolderVersionDalFactory } from "@app/services/secret-folder/secret-folder-version-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import {
TGetSnapshotDataDTO,
TProjectSnapshotCountDTO,
TProjectSnapshotListDTO,
TRollbackSnapshotDTO
} from "./secret-snapshot-types";
import { TSnapshotDalFactory } from "./snapshot-dal";
import { TSnapshotFolderDalFactory } from "./snapshot-folder-dal";
import { TSnapshotSecretDalFactory } from "./snapshot-secret-dal";
type TSecretSnapshotServiceFactoryDep = {
snapshotDal: TSnapshotDalFactory;
snapshotSecretDal: TSnapshotSecretDalFactory;
snapshotFolderDal: TSnapshotFolderDalFactory;
secretVersionDal: Pick<TSecretVersionDalFactory, "insertMany" | "findLatestVersionByFolderId">;
folderVersionDal: Pick<
TSecretFolderVersionDalFactory,
"findLatestVersionByFolderId" | "insertMany"
>;
secretDal: Pick<TSecretDalFactory, "delete" | "insertMany">;
folderDal: Pick<
TSecretFolderDalFactory,
"findById" | "findBySecretPath" | "delete" | "insertMany"
>;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TSecretSnapshotServiceFactory = ReturnType<typeof secretSnapshotServiceFactory>;
export const secretSnapshotServiceFactory = ({
snapshotDal,
folderVersionDal,
secretVersionDal,
snapshotSecretDal,
snapshotFolderDal,
folderDal,
secretDal,
permissionService
}: TSecretSnapshotServiceFactoryDep) => {
const projectSecretSnapshotCount = async ({
environment,
projectId,
actorId,
actor,
path
}: TProjectSnapshotCountDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const count = await snapshotDal.countOfSnapshotsByFolderId(folder.id);
return count;
};
const listSnapshots = async ({
environment,
projectId,
actorId,
actor,
path,
limit = 20,
offset = 0
}: TProjectSnapshotListDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const snapshots = await snapshotDal.find(
{ folderId: folder.id },
{ limit, offset, sort: [["createdAt", "desc"]] }
);
return snapshots;
};
const getSnapshotData = async ({ actorId, actor, id }: TGetSnapshotDataDTO) => {
const snapshot = await snapshotDal.findSecretSnapshotDataById(id);
if (!snapshot) throw new BadRequestError({ message: "Snapshot not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
snapshot.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
);
return snapshot;
};
const performSnapshot = async (folderId: string) => {
const snapshot = await snapshotDal.transaction(async (tx) => {
const folder = await folderDal.findById(folderId, tx);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const secretVersions = await secretVersionDal.findLatestVersionByFolderId(folderId, tx);
const folderVersions = await folderVersionDal.findLatestVersionByFolderId(folderId, tx);
const newSnapshot = await snapshotDal.create(
{
folderId,
envId: folder.environment.envId,
parentFolderId: folder.parentId
},
tx
);
const snapshotSecrets = await snapshotSecretDal.insertMany(
secretVersions.map(({ id }) => ({
secretVersionId: id,
envId: folder.environment.envId,
snapshotId: newSnapshot.id
})),
tx
);
const snapshotFolders = await snapshotFolderDal.insertMany(
folderVersions.map(({ id }) => ({
folderVersionId: id,
envId: folder.environment.envId,
snapshotId: newSnapshot.id
})),
tx
);
return { ...newSnapshot, secrets: snapshotSecrets, folder: snapshotFolders };
});
return snapshot;
};
const rollbackSnapshot = async ({ id: snapshotId, actor, actorId }: TRollbackSnapshotDTO) => {
const snapshot = await snapshotDal.findById(snapshotId);
if (!snapshot) throw new BadRequestError({ message: "Snapshot not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
snapshot.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.SecretRollback
);
const rollback = await snapshotDal.transaction(async (tx) => {
const rollbackSnaps = await snapshotDal.findRecursivelySnapshots(snapshot.id, tx);
// this will remove all secrets in current folder
const deletedTopLevelSecs = await secretDal.delete({ folderId: snapshot.folderId }, tx);
const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id);
// this will remove all secrets and folders on child
// due to sql foreign key and link list connection removing the folders removes everything below too
const deletedFolders = await folderDal.delete({ parentId: snapshot.folderId }, tx);
const deletedTopLevelFolders = groupBy(
deletedFolders.filter(({ parentId }) => parentId === snapshot.folderId),
(item) => item.id
);
const folders = await folderDal.insertMany(
rollbackSnaps.flatMap(({ folderVersion, folderId }) =>
folderVersion.map(({ name, id, latestFolderVersion }) => ({
envId: snapshot.envId,
id,
version: latestFolderVersion + 1,
name,
parentId: folderId
}))
),
tx
);
const secrets = await secretDal.insertMany(
rollbackSnaps.flatMap(({ secretVersions, folderId }) =>
secretVersions.map(
({
latestSecretVersion,
version,
updatedAt,
createdAt,
secretId,
envId,
id,
...el
}) => ({
...el,
id: secretId,
version: latestSecretVersion + 1,
folderId
})
)
),
tx
);
const folderVersions = await folderVersionDal.insertMany(
folders.map(({ version, name, id, envId }) => ({
name,
version,
folderId: id,
envId
})),
tx
);
const secretVersions = await secretVersionDal.insertMany(
secrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, secretId: id })),
tx
);
const newSnapshot = await snapshotDal.create(
{
folderId: snapshot.folderId,
envId: snapshot.envId,
parentFolderId: snapshot.parentFolderId
},
tx
);
const snapshotSecrets = await snapshotSecretDal.insertMany(
secretVersions
.filter(({ secretId }) => Boolean(deletedTopLevelSecsGroupById?.[secretId]))
.map(({ id }) => ({
secretVersionId: id,
envId: newSnapshot.envId,
snapshotId: newSnapshot.id
})),
tx
);
const snapshotFolders = await snapshotFolderDal.insertMany(
folderVersions
.filter(({ folderId }) => Boolean(deletedTopLevelFolders?.[folderId]))
.map(({ id }) => ({
folderVersionId: id,
envId: newSnapshot.envId,
snapshotId: newSnapshot.id
})),
tx
);
return { ...newSnapshot, snapshotSecrets, snapshotFolders };
});
return rollback;
};
return {
performSnapshot,
projectSecretSnapshotCount,
listSnapshots,
getSnapshotData,
rollbackSnapshot
};
};

View File

@@ -0,0 +1,21 @@
import { TProjectPermission } from "@app/lib/types";
export type TProjectSnapshotCountDTO = {
environment: string;
path: string;
} & TProjectPermission;
export type TProjectSnapshotListDTO = {
environment: string;
path: string;
offset?: number;
limit?: number;
} & TProjectPermission;
export type TGetSnapshotDataDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TRollbackSnapshotDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -0,0 +1,298 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import {
SecretVersionsSchema,
TableName,
TSecretFolderVersions,
TSecretSnapshotFolders,
TSecretSnapshots,
TSecretVersions} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TSnapshotDalFactory = ReturnType<typeof snapshotDalFactory>;
export const snapshotDalFactory = (db: TDbClient) => {
const secretSnapshotOrm = ormify(db, TableName.Snapshot);
const findById = async (id: string, tx?: Knex) => {
try {
const data = await (tx || db)(TableName.Snapshot)
.where(`${TableName.Snapshot}.id`, id)
.join(TableName.Environment, `${TableName.Snapshot}.envId`, `${TableName.Environment}.id`)
.select(selectAllTableCols(TableName.Snapshot))
.select(
db.ref("id").withSchema(TableName.Environment).as("envId"),
db.ref("projectId").withSchema(TableName.Environment),
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug")
)
.first();
if (data) {
const { envId, envName, envSlug } = data;
return { ...data, envId, enviroment: { id: envId, name: envName, slug: envSlug } };
}
} catch (error) {
throw new DatabaseError({ error, name: "FindById" });
}
};
const countOfSnapshotsByFolderId = async (folderId: string, tx?: Knex) => {
try {
const doc = await (tx || db)(TableName.Snapshot)
.where({ folderId })
.groupBy(["folderId"])
.count("folderId")
.first();
return parseInt((doc?.count as string) || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "CountOfProjectSnapshot" });
}
};
const findSecretSnapshotDataById = async (snapshotId: string, tx?: Knex) => {
try {
const data = await (tx || db)(TableName.Snapshot)
.where(`${TableName.Snapshot}.id`, snapshotId)
.join(TableName.Environment, `${TableName.Snapshot}.envId`, `${TableName.Environment}.id`)
.leftJoin(
TableName.SnapshotSecret,
`${TableName.Snapshot}.id`,
`${TableName.SnapshotSecret}.snapshotId`
)
.leftJoin(
TableName.SecretVersion,
`${TableName.SnapshotSecret}.secretVersionId`,
`${TableName.SecretVersion}.id`
)
.leftJoin(
TableName.SnapshotFolder,
`${TableName.SnapshotFolder}.snapshotId`,
`${TableName.Snapshot}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.SnapshotFolder}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.select(selectAllTableCols(TableName.SecretVersion))
.select(
db.ref("id").withSchema(TableName.Snapshot).as("snapshotId"),
db.ref("createdAt").withSchema(TableName.Snapshot).as("snapshotCreatedAt"),
db.ref("updatedAt").withSchema(TableName.Snapshot).as("snapshotUpdatedAt"),
db.ref("id").withSchema(TableName.Environment).as("envId"),
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("projectId").withSchema(TableName.Environment),
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderVerName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderVerId")
);
return sqlNestRelationships({
data,
key: "snapshotId",
parentMapper: ({
snapshotId: id,
projectId,
envId,
envSlug,
envName,
snapshotCreatedAt: createdAt,
snapshotUpdatedAt: updatedAt
}) => ({
id,
projectId,
createdAt,
updatedAt,
environment: { id: envId, slug: envSlug, name: envName }
}),
childrenMapper: [
{
key: "id",
label: "secretVersions",
mapper: (el) => SecretVersionsSchema.parse(el)
},
{
key: "folderVerId",
label: "folderVersion",
mapper: ({ folderVerId: id, folderVerName: name }) => ({ id, name })
}
] as const
})?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "FindSecretSnapshotDataById" });
}
};
// this is used for rollback
// from a starting snapshot it will collect all the secrets and folder of that
// then it will start go through recursively the below folders latest snapshots then their child folder snapshot until leaf node
// the recursive part find all snapshot id
// then joins with respective secrets and folder
const findRecursivelySnapshots = async (snapshotId: string, tx?: Knex) => {
try {
const data = await (tx || db)
.withRecursive("parent", (qb) => {
qb.from(TableName.Snapshot)
.leftJoin<TSecretSnapshotFolders>(
TableName.SnapshotFolder,
`${TableName.SnapshotFolder}.snapshotId`,
`${TableName.Snapshot}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.SnapshotFolder}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.select(selectAllTableCols(TableName.Snapshot))
.select({ depth: 1 })
.select(
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderVerName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderVerId")
)
.where(`${TableName.Snapshot}.id`, snapshotId)
.union((cb) =>
cb
.select(selectAllTableCols(TableName.Snapshot))
.select({ depth: db.raw("parent.depth + 1") })
.select(
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderVerName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderVerId")
)
.from(TableName.Snapshot)
.join<TSecretSnapshots, TSecretSnapshots & { secretId: string; max: number }>(
db(TableName.Snapshot)
.groupBy("folderId")
.max("createdAt")
.select("folderId")
.as("latestVersion"),
`${TableName.Snapshot}.createdAt`,
"latestVersion.max"
)
.leftJoin<TSecretSnapshotFolders>(
TableName.SnapshotFolder,
`${TableName.SnapshotFolder}.snapshotId`,
`${TableName.Snapshot}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.SnapshotFolder}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.join("parent", "parent.folderVerId", `${TableName.Snapshot}.folderId`)
);
})
.orderBy("depth", "asc")
.from<TSecretSnapshots & { folderVerId: string; folderVerName: string }>("parent")
.leftJoin<TSecretSnapshots>(
TableName.SnapshotSecret,
`parent.id`,
`${TableName.SnapshotSecret}.snapshotId`
)
.leftJoin<TSecretVersions>(
TableName.SecretVersion,
`${TableName.SnapshotSecret}.secretVersionId`,
`${TableName.SecretVersion}.id`
)
.leftJoin<{ latestSecretVersion: number }>(
(tx || db)(TableName.SecretVersion)
.groupBy("secretId")
.select("secretId")
.max("version")
.as("secGroupByMaxVersion"),
`${TableName.SecretVersion}.secretId`,
"secGroupByMaxVersion.secretId"
)
.leftJoin<{ latestFolderVersion: number }>(
(tx || db)(TableName.SecretFolderVersion)
.groupBy("folderId")
.select("folderId")
.max("version")
.as("folderGroupByMaxVersion"),
`parent.folderId`,
"folderGroupByMaxVersion.folderId"
)
.select(selectAllTableCols(TableName.SecretVersion))
.select(
db.ref("id").withSchema("parent").as("snapshotId"),
db.ref("folderId").withSchema("parent").as("snapshotFolderId"),
db.ref("parentFolderId").withSchema("parent").as("snapshotParentFolderId"),
db.ref("folderVerName").withSchema("parent"),
db.ref("folderVerId").withSchema("parent"),
db.ref("max").withSchema("secGroupByMaxVersion").as("latestSecretVersion"),
db.ref("max").withSchema("folderGroupByMaxVersion").as("latestFolderVersion")
);
const formated = sqlNestRelationships({
data,
key: "snapshotId",
parentMapper: ({
snapshotId: id,
snapshotFolderId: folderId,
snapshotParentFolderId: parentFolderId
}) => ({
id,
folderId,
parentFolderId
}),
childrenMapper: [
{
key: "id",
label: "secretVersions",
mapper: (el) => ({
...SecretVersionsSchema.parse(el),
latestSecretVersion: el.latestSecretVersion
})
},
{
key: "folderVerId",
label: "folderVersion",
mapper: ({ folderVerId: id, folderVerName: name, latestFolderVersion }) => ({
id,
name,
latestFolderVersion
})
}
] as const
});
return formated;
} catch (error) {
throw new DatabaseError({ error, name: "FindRecursivelySnapshots" });
}
};
// instead of copying all child folders
// we will take the latest snapshot of those folders
// when we need to rollback we will pull from these snapshots
const findLatestSnapshotByFolderId = async (folderId: string, tx?: Knex) => {
try {
const docs = await (tx || db)(TableName.Snapshot)
.where(`${TableName.Snapshot}.folderId`, folderId)
.join<TSecretSnapshots>(
(tx || db)(TableName.Snapshot)
.groupBy("folderId")
.max("createdAt")
.select("folderId")
.as("latestVersion"),
(bd) => {
bd.on(`${TableName.Snapshot}.folderId`, "latestVersion.folderId").andOn(
`${TableName.Snapshot}.createdAt`,
"latestVersion.max"
);
}
)
.first();
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestVersionByFolderId" });
}
};
return {
...secretSnapshotOrm,
findById,
findLatestSnapshotByFolderId,
findRecursivelySnapshots,
countOfSnapshotsByFolderId,
findSecretSnapshotDataById
};
};

View File

@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSnapshotFolderDalFactory = ReturnType<typeof snapshotFolderDalFactory>;
export const snapshotFolderDalFactory = (db: TDbClient) => {
const snapshotFolderOrm = ormify(db, TableName.SnapshotFolder);
return snapshotFolderOrm;
};

View File

@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSnapshotSecretDalFactory = ReturnType<typeof snapshotSecretDalFactory>;
export const snapshotSecretDalFactory = (db: TDbClient) => {
const snapshotSecretOrm = ormify(db, TableName.SnapshotSecret);
return snapshotSecretOrm;
};

190
backend-pg/src/folder.ts Normal file
View File

@@ -0,0 +1,190 @@
import dotenv from "dotenv";
import { initDbConnection } from "./db";
import {
SecretVersionsSchema,
TableName,
TSecretFolderVersions,
TSecrets,
TSecretSnapshotFolders,
TSecretSnapshots,
TSecretVersions} from "./db/schemas";
import { selectAllTableCols, sqlNestRelationships } from "./lib/knex";
dotenv.config();
// const db = initDbConnection(process.env.DB_CONNECTION_URI);
// const main = async () => {
// const folders = db
// .withRecursive("parent", (qb) => {
// qb.select({
// depth: 1,
// path: db.raw("'/'")
// })
// .select(selectAllTableCols(db, TableName.SecretFolder))
// .from(TableName.SecretFolder)
// .join(
// TableName.Environment,
// `${TableName.SecretFolder}.envId`,
// `${TableName.Environment}.id`
// )
// .where({
// projectId: "01c10de1-8743-490f-9c8a-7a19c4dc72a9",
// parentId: null
// })
// .where(`${TableName.Environment}.slug`, "dev")
// .union((qb) =>
// qb
// .select({
// depth: db.raw("parent.depth + 1"),
// path: db.raw(
// "CONCAT((CASE WHEN parent.path = '/' THEN '' ELSE parent.path END),'/', secret_folders.name)"
// )
// })
// .select(selectAllTableCols(db, TableName.SecretFolder))
// .whereRaw(
// `depth = array_position(ARRAY[${[1, 2]
// .map((_) => "?")
// .join(",")}]::varchar[], secret_folders.name,depth)`,
// [...["ui", "design"]]
// )
// .from(TableName.SecretFolder)
// .join("parent", "parent.id", `${TableName.SecretFolder}.parentId`)
// );
// })
// .select("*")
// .from("parent")
// .orderBy("depth", "desc")
// .first();
// console.log(folders.toSQL());
// console.log(JSON.stringify(await folders, null, 4));
// process.exit(0);
// };
//
const main = async () => {
const db = initDbConnection(process.env.DB_CONNECTION_URI);
const folders = db
.withRecursive("parent", (qb) => {
qb.from(TableName.Snapshot)
.leftJoin<TSecretSnapshotFolders>(
TableName.SnapshotFolder,
`${TableName.SnapshotFolder}.snapshotId`,
`${TableName.Snapshot}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.SnapshotFolder}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.select(selectAllTableCols(TableName.Snapshot))
.select({ depth: 1 })
.select(
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderVerName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderVerId")
)
.where(`${TableName.Snapshot}.id`, "abe694d3-957f-40f5-a907-952d3e5ccaf1")
.union((cb) =>
cb
.select(selectAllTableCols(TableName.Snapshot))
.select({ depth: db.raw("parent.depth + 1") })
.select(
db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderVerName"),
db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderVerId")
)
.from(TableName.Snapshot)
.join<TSecretSnapshots, TSecretSnapshots & { secretId: string; max: number }>(
db(TableName.Snapshot)
.groupBy("folderId")
.max("createdAt")
.select("folderId")
.as("latestVersion"),
`${TableName.Snapshot}.createdAt`,
"latestVersion.max"
)
.leftJoin<TSecretSnapshotFolders>(
TableName.SnapshotFolder,
`${TableName.SnapshotFolder}.snapshotId`,
`${TableName.Snapshot}.id`
)
.leftJoin<TSecretFolderVersions>(
TableName.SecretFolderVersion,
`${TableName.SnapshotFolder}.folderVersionId`,
`${TableName.SecretFolderVersion}.id`
)
.join("parent", "parent.folderVerId", `${TableName.Snapshot}.folderId`)
);
})
.orderBy("depth", "asc")
.from<TSecretSnapshots & { folderVerId: string; folderVerName: string }>("parent")
.leftJoin<TSecretSnapshots>(
TableName.SnapshotSecret,
`parent.id`,
`${TableName.SnapshotSecret}.snapshotId`
)
.leftJoin<TSecretVersions>(
TableName.SecretVersion,
`${TableName.SnapshotSecret}.secretVersionId`,
`${TableName.SecretVersion}.id`
)
.leftJoin<{ latestSecretVersion: number }>(
db(TableName.SecretVersion)
.groupBy("secretId")
.select("secretId")
.max("version")
.as("secGroupByMaxVersion"),
`${TableName.SecretVersion}.secretId`,
"secGroupByMaxVersion.secretId"
)
.leftJoin<{ latestFolderVersion: number }>(
db(TableName.SecretFolderVersion)
.groupBy("folderId")
.select("folderId")
.max("version")
.as("folderGroupByMaxVersion"),
`parent.folderId`,
"folderGroupByMaxVersion.folderId"
)
.select(selectAllTableCols(TableName.SecretVersion))
.select(
db.ref("id").withSchema("parent").as("snapshotId"),
db.ref("folderId").withSchema("parent").as("snapshotFolderId"),
db.ref("parentFolderId").withSchema("parent").as("snapshotParentFolderId"),
db.ref("folderVerName").withSchema("parent"),
db.ref("folderVerId").withSchema("parent"),
db.ref("max").withSchema("secGroupByMaxVersion").as("latestSecretVersion"),
db.ref("max").withSchema("folderGroupByMaxVersion").as("latestFolderVersion")
);
console.log(folders.toSQL());
const data = await folders;
// console.log(data);
const formated = sqlNestRelationships({
data,
key: "snapshotId",
parentMapper: ({ snapshotId: id }) => ({
id
}),
childrenMapper: [
{
key: "id",
label: "secretVersions",
mapper: (el) => ({
...SecretVersionsSchema.parse(el),
latestSecretVersion: el.latestSecretVersion
})
},
{
key: "folderVerId",
label: "folderVersion",
mapper: ({ folderVerId: id, folderVerName: name, latestFolderVersion }) => ({
id,
name,
version: latestFolderVersion
})
}
] as const
});
console.log(formated);
process.exit(0);
};
main();

View File

@@ -0,0 +1,18 @@
/**
* Sorts an array of items into groups. The return value is a map where the keys are
* the group ids the given getGroupId function produced and the value is an array of
* each item in that group.
*/
export const groupBy = <T, Key extends string | number | symbol>(
array: readonly T[],
getGroupId: (item: T) => Key
): Record<Key, T[]> =>
array.reduce(
(acc, item) => {
const groupId = getGroupId(item);
if (!acc[groupId]) acc[groupId] = [];
acc[groupId].push(item);
return acc;
},
{} as Record<Key, T[]>
);

View File

@@ -0,0 +1,5 @@
// Some of the functions are taken from https://github.com/rayepps/radash
// Full credits goes to https://github.com/rayapps to those functions
// Code taken to keep in in house and to adjust somethings for our needs
export * from "./array";
export * from "./object";

View File

@@ -0,0 +1,17 @@
/**
* Pick a list of properties from an object
* into a new object
*/
export const pick = <T extends object, TKeys extends keyof T>(
obj: T,
keys: TKeys[]
): Pick<T, TKeys> => {
if (!obj) return {} as Pick<T, TKeys>;
return keys.reduce(
(acc, key) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) acc[key] = obj[key];
return acc;
},
{} as Pick<T, TKeys>
);
};

View File

@@ -16,7 +16,7 @@ export const withTransaction = <K extends object>(db: Knex, dal: K) => ({
});
export type TFindFilter<R extends {} = any> = Partial<R> & {
$in?: Partial<{ [K in keyof R]: R[K][] }>;
$in?: Partial<{ [k in keyof R]: R[k][] }>;
};
export const buildFindFilter =
<R extends {} = any>({ $in, ...filter }: TFindFilter<R>) =>
@@ -30,6 +30,13 @@ export const buildFindFilter =
return bd;
};
export type TFindOpt<R extends {} = any> = {
limit?: number;
offset?: number;
sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>;
tx?: Knex;
};
// What is ormify
// It is to inject typical operations like find, findOne, update, delete, create
// This will avoid writing most common ones each time
@@ -60,9 +67,20 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(
throw new DatabaseError({ error, name: "Find one" });
}
},
find: async (filter: TFindFilter<Tables[Tname]["base"]>, tx?: Knex) => {
find: async (
filter: TFindFilter<Tables[Tname]["base"]>,
{ offset, limit, sort, tx }: TFindOpt<Tables[Tname]["base"]> = {}
) => {
try {
const res = await (tx || db)(tableName).where(buildFindFilter(filter));
const query = (tx || db)(tableName).where(buildFindFilter(filter));
if (limit) query.limit(limit);
if (offset) query.offset(offset);
if (sort) {
query.orderBy(
sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))
);
}
const res = await query;
return res;
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });
@@ -78,6 +96,7 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(
},
insertMany: async (data: readonly Tables[Tname]["insert"][], tx?: Knex) => {
try {
if (!data.length) return [];
const res = await (tx || db)(tableName)
.insert(data as any)
.returning("*");

View File

@@ -76,12 +76,14 @@ export const sqlNestRelationships = <
parentLookup.add(pk);
}
childrenMapper.forEach(({ label, mapper, key: cKey }) => {
const ck = `${pk}-${label}-${el[cKey]}`;
if (!childLookUp.has(ck)) {
if (!recordsGroupedByPk[pk][label]) recordsGroupedByPk[pk][label as keyof Cm] = [] as any;
const val = mapper(el);
if (typeof val !== "undefined") recordsGroupedByPk[pk][label].push(val);
childLookUp.add(ck);
if (!recordsGroupedByPk[pk][label]) recordsGroupedByPk[pk][label as keyof Cm] = [] as any;
if (el[cKey] !== null && typeof el[cKey] !== "undefined") {
const ck = `${pk}-${label}-${el[cKey]}`;
if (!childLookUp.has(ck)) {
const val = mapper(el);
if (typeof val !== "undefined" && val !== null) recordsGroupedByPk[pk][label].push(val);
childLookUp.add(ck);
}
}
});
});

View File

@@ -18,7 +18,7 @@ export type TQueueJobTypes = {
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
export const queueServiceFactory = (redisUrl: string) => {
const connection = new Redis(redisUrl);
const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
const queueContainer: Record<
QueueName,
Queue<TQueueJobTypes[QueueName]["payload"], void, TQueueJobTypes[QueueName]["name"]>
@@ -46,7 +46,7 @@ export const queueServiceFactory = (redisUrl: string) => {
TQueueJobTypes[T]["payload"],
void,
TQueueJobTypes[T]["name"]
>(name, jobFn);
>(name, jobFn, { connection });
};
const listen = async <

View File

@@ -14,6 +14,10 @@ import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-app
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 { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { snapshotDalFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
import { snapshotFolderDalFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal";
import { snapshotSecretDalFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
import { getConfig } from "@app/lib/config/env";
import { TQueueServiceFactory } from "@app/queue";
import { apiKeyDalFactory } from "@app/services/api-key/api-key-dal";
@@ -61,6 +65,7 @@ import { secretServiceFactory } from "@app/services/secret/secret-service";
import { secretVersionDalFactory } from "@app/services/secret/secret-version-dal";
import { secretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal";
import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
import { secretFolderVersionDalFactory } from "@app/services/secret-folder/secret-folder-version-dal";
import { secretImportDalFactory } from "@app/services/secret-import/secret-import-dal";
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { secretTagDalFactory } from "@app/services/secret-tag/secret-tag-dal";
@@ -109,6 +114,7 @@ export const registerRoutes = async (
const secretDal = secretDalFactory(db);
const secretTagDal = secretTagDalFactory(db);
const folderDal = secretFolderDalFactory(db);
const folderVersionDal = secretFolderVersionDalFactory(db);
const secretImportDal = secretImportDalFactory(db);
const secretVersionDal = secretVersionDalFactory(db);
const secretBlindIndexDal = secretBlindIndexDalFactory(db);
@@ -135,6 +141,9 @@ export const registerRoutes = async (
const sarSecretDal = sarSecretDalFactory(db);
const secretRotationDal = secretRotationDalFactory(db);
const snapshotDal = snapshotDalFactory(db);
const snapshotSecretDal = snapshotSecretDalFactory(db);
const snapshotFolderDal = snapshotFolderDalFactory(db);
const permissionService = permissionServiceFactory({ permissionDal, orgRoleDal, projectRoleDal });
const sapService = secretApprovalPolicyServiceFactory({
@@ -215,19 +224,33 @@ export const registerRoutes = async (
});
const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDal });
const snapshotService = secretSnapshotServiceFactory({
folderDal,
secretDal,
snapshotDal,
snapshotFolderDal,
snapshotSecretDal,
secretVersionDal,
folderVersionDal,
permissionService
});
const secretService = secretServiceFactory({
folderDal,
secretVersionDal,
secretBlindIndexDal,
permissionService,
secretDal,
secretTagDal
secretTagDal,
snapshotService
});
const secretTagService = secretTagServiceFactory({ secretTagDal, permissionService });
const folderService = secretFolderServiceFactory({
permissionService,
folderDal,
projectEnvDal
folderVersionDal,
projectEnvDal,
snapshotService
});
const secretImportService = secretImportServiceFactory({
projectEnvDal,
@@ -329,7 +352,8 @@ export const registerRoutes = async (
identityUa: identityUaService,
secretApprovalPolicy: sapService,
secretApprovalRequest: sarService,
secretRotation: secretRotationService
secretRotation: secretRotationService,
snapshot: snapshotService
});
server.decorate<FastifyZodProvider["store"]>("store", {

View File

@@ -24,7 +24,7 @@ export type TGetUaDTO = {
export type TCreateUaClientSecretDTO = {
identityId: string;
description?: string;
description: string;
numUsesLimit: number;
ttl: number;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -1,7 +1,7 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TSecretFolders } from "@app/db/schemas";
import { TableName, TSecretFolders, TSecretFoldersUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
@@ -35,9 +35,9 @@ const sqlFindFolderByPathQuery = (
baseQb
.select({
depth: 1,
// latestFolderVerId: db.raw("NULL::uuid"),
path: db.raw("'/'")
})
.select(selectAllTableCols(TableName.SecretFolder))
.from(TableName.SecretFolder)
.join(
TableName.Environment,
@@ -49,6 +49,7 @@ const sqlFindFolderByPathQuery = (
parentId: null
})
.where(`${TableName.Environment}.slug`, environment)
.select(selectAllTableCols(TableName.SecretFolder))
.union((qb) =>
// for here on we keep going to next child node.
// we also keep a measure of depth then we check the depth matches the array path segment and folder name
@@ -100,5 +101,44 @@ export const secretFolderDalFactory = (db: TDbClient) => {
}
};
return { ...secretFolderOrm, findBySecretPath };
const update = async (filter: Partial<TSecretFolders>, data: TSecretFoldersUpdate, tx?: Knex) => {
try {
const folder = await (tx || db)(TableName.SecretFolder)
.where(filter)
.update(data)
.increment("version", 1)
.returning("*");
return folder;
} catch (error) {
throw new DatabaseError({ error, name: "SecretFolderUpdate" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const folder = await (tx || db)(TableName.SecretFolder)
.where({ [`${TableName.SecretFolder}.id` as "id"]: id })
.join(
TableName.Environment,
`${TableName.SecretFolder}.envId`,
`${TableName.Environment}.id`
)
.select(selectAllTableCols(TableName.SecretFolder))
.select(
db.ref("id").withSchema(TableName.Environment).as("envId"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("projectId").withSchema(TableName.Environment)
)
.first();
if (folder) {
const { envId, envName, envSlug, ...el } = folder;
return { ...el, environment: { envId, envName, envSlug } };
}
} catch (error) {
throw new DatabaseError({ error, name: "Find by id" });
}
};
return { ...secretFolderOrm, update, findBySecretPath, findById };
};

View File

@@ -5,6 +5,7 @@ import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { BadRequestError } from "@app/lib/errors";
import { TProjectEnvDalFactory } from "../project-env/project-env-dal";
@@ -15,19 +16,24 @@ import {
TGetFolderDTO,
TUpdateFolderDTO
} from "./secret-folder-types";
import { TSecretFolderVersionDalFactory } from "./secret-folder-version-dal";
type TSecretFolderServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDal: TSecretFolderDalFactory;
projectEnvDal: Pick<TProjectEnvDalFactory, "findOne">;
folderVersionDal: TSecretFolderVersionDalFactory;
};
export type TSecretFolderServiceFactory = ReturnType<typeof secretFolderServiceFactory>;
export const secretFolderServiceFactory = ({
folderDal,
snapshotService,
permissionService,
projectEnvDal
projectEnvDal,
folderVersionDal
}: TSecretFolderServiceFactoryDep) => {
const createFolder = async ({
projectId,
@@ -54,9 +60,19 @@ export const secretFolderServiceFactory = ({
{ name, envId: env.id, version: 1, parentId: parentFolder.id },
tx
);
await folderVersionDal.create(
{
name: doc.name,
envId: doc.envId,
version: doc.version,
folderId: doc.id
},
tx
);
return doc;
});
await snapshotService.performSnapshot(folder.parentId as string);
return folder;
};
@@ -85,13 +101,23 @@ export const secretFolderServiceFactory = ({
const [doc] = await folderDal.update(
{ envId: env.id, id, parentId: parentFolder.id },
{ name, version: 1 },
{ name },
tx
);
await folderVersionDal.create(
{
name: doc.name,
envId: doc.envId,
version: doc.version,
folderId: doc.id
},
tx
);
if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Update folder" });
return doc;
});
await snapshotService.performSnapshot(folder.parentId as string);
return folder;
};
@@ -122,6 +148,7 @@ export const secretFolderServiceFactory = ({
return doc;
});
await snapshotService.performSnapshot(folder.parentId as string);
return folder;
};

View File

@@ -0,0 +1,72 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TSecretFolderVersions } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TSecretFolderVersionDalFactory = ReturnType<typeof secretFolderVersionDalFactory>;
export const secretFolderVersionDalFactory = (db: TDbClient) => {
const secretFolderVerOrm = ormify(db, TableName.SecretFolderVersion);
// This will fetch all latest secret versions from a folder
const findLatestVersionByFolderId = async (folderId: string, tx?: Knex) => {
try {
const docs = await (tx || db)(TableName.SecretFolderVersion)
.join(
TableName.SecretFolder,
`${TableName.SecretFolderVersion}.folderId`,
`${TableName.SecretFolder}.id`
)
.where({ parentId: folderId })
.join<TSecretFolderVersions>(
(tx || db)(TableName.SecretFolderVersion)
.groupBy("envId", "folderId")
.max("version")
.select("folderId")
.as("latestVersion"),
(bd) => {
bd.on(`${TableName.SecretFolderVersion}.folderId`, "latestVersion.folderId").andOn(
`${TableName.SecretFolderVersion}.version`,
"latestVersion.max"
);
}
)
.select(selectAllTableCols(TableName.SecretFolderVersion));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestVersionByFolderId" });
}
};
const findLatestFolderVersions = async (folderIds: string[], tx?: Knex) => {
try {
const docs: Array<TSecretFolderVersions & { max: number }> = await (tx || db)(
TableName.SecretFolderVersion
)
.whereIn("folderId", folderIds)
.join(
(tx || db)(TableName.SecretFolderVersion)
.groupBy("folderId")
.max("version")
.select("folderId")
.as("latestVersion"),
(bd) => {
bd.on(`${TableName.SecretFolderVersion}.folderId`, "latestVersion.folderId").andOn(
`${TableName.SecretFolderVersion}.version`,
"latestVersion.max"
);
}
);
return docs.reduce<Record<string, TSecretFolderVersions>>(
(prev, curr) => ({ ...prev, [curr.folderId || ""]: curr }),
{}
);
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestFolderVersions" });
}
};
return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId };
};

View File

@@ -2,7 +2,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { SecretType, TableName, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { mergeOneToManyRelation, ormify, selectAllTableCols } from "@app/lib/knex";
export type TSecretDalFactory = ReturnType<typeof secretDalFactory>;
@@ -29,10 +29,7 @@ export const secretDalFactory = (db: TDbClient) => {
// the idea is to use postgres specific function
// insert with id this will cause a conflict then merge the data
const bulkUpdate = async (
data: Array<TSecretsUpdate & { id: string }>,
tx?: Knex
) => {
const bulkUpdate = async (data: Array<TSecretsUpdate & { id: string }>, tx?: Knex) => {
try {
const secs = await (tx || db)(TableName.Secret)
.insert(data as TSecretsInsert[])
@@ -59,7 +56,7 @@ export const secretDalFactory = (db: TDbClient) => {
bd.orWhere({
secretBlindIndex: el.blindIndex,
type: el.type,
userId: el.type === SecretType.Personal ? userId : null
...(el.type === SecretType.Personal ? { userId } : {})
});
});
})
@@ -123,6 +120,9 @@ export const secretDalFactory = (db: TDbClient) => {
.where({ folderId })
.where((bd) => {
blindIndexes.forEach((el) => {
if (el.type === SecretType.Personal && !userId) {
throw new BadRequestError({ message: "Missing personal user id" });
}
bd.orWhere({
secretBlindIndex: el.blindIndex,
type: el.type,

View File

@@ -5,18 +5,20 @@ import {
SecretKeyEncoding,
SecretType,
TableName,
TSecretBlindIndexes,
TSecrets
TSecretBlindIndexes
} from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { getConfig } from "@app/lib/config/env";
import { buildSecretBlindIndexFromName } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { groupBy, pick } from "@app/lib/fn";
import { ActorType } from "../auth/auth-type";
import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal";
import { TSecretTagDalFactory } from "../secret-tag/secret-tag-dal";
import { TSecretBlindIndexDalFactory } from "./secret-blind-index-dal";
@@ -26,8 +28,13 @@ import {
TCreateSecretDTO,
TDeleteBulkSecretDTO,
TDeleteSecretDTO,
TFnSecretBlindIndexCheck,
TFnSecretBulkDelete,
TFnSecretBulkInsert,
TFnSecretBulkUpdate,
TGetASecretDTO,
TGetSecretsDTO,
TListSecretVersionDTO,
TUpdateBulkSecretDTO,
TUpdateSecretDTO
} from "./secret-types";
@@ -37,9 +44,10 @@ type TSecretServiceFactoryDep = {
secretDal: TSecretDalFactory;
secretTagDal: TSecretTagDalFactory;
secretVersionDal: TSecretVersionDalFactory;
folderDal: TSecretFolderDalFactory;
folderDal: Pick<TSecretFolderDalFactory, "findBySecretPath" | "updateById" | "findById">;
secretBlindIndexDal: TSecretBlindIndexDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
};
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
@@ -67,10 +75,11 @@ export const secretServiceFactory = ({
secretVersionDal,
folderDal,
secretBlindIndexDal,
permissionService
permissionService,
snapshotService
}: TSecretServiceFactoryDep) => {
// utility function to get secret blind index data
const generateSecretBlindIndexByName = async (projectId: string, secretName: string) => {
const interalGenSecBlindIndexByName = async (projectId: string, secretName: string) => {
const appCfg = getConfig();
const secretBlindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
@@ -90,6 +99,136 @@ export const secretServiceFactory = ({
return secretBlindIndex;
};
// these functions are special functions shared by a couple of resources
// used by secret approval, rotation or anywhere in which secret needs to modified
const fnSecretBulkInsert = async ({ folderId, inputSecrets, tx }: TFnSecretBulkInsert) => {
const newSecrets = await secretDal.insertMany(
inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })),
tx
);
const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex);
const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) =>
secretTags.map((tag) => ({
[`${TableName.SecretTag}Id`]: tag,
[`${TableName.Secret}Id`]: newSecretGroupByBlindIndex[secretBlindIndex][0].id
}))
);
if (newSecretTags.length) {
await secretTagDal.saveTagsToSecret(newSecretTags, tx);
}
await secretVersionDal.insertMany(
inputSecrets.map(({ tags, ...el }) => ({
...el,
folderId,
secretId: newSecretGroupByBlindIndex[el.secretBlindIndex][0].id
})),
tx
);
return newSecrets;
};
const fnSecretBulkUpdate = async ({
tx,
inputSecrets,
folderId,
projectId
}: TFnSecretBulkUpdate) => {
const newSecrets = await secretDal.bulkUpdate(
inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })),
tx
);
const secsUpdatedTag = inputSecrets.filter(({ tags }) => Boolean(tags));
if (secsUpdatedTag.length) {
await secretTagDal.deleteTagsManySecret(
projectId,
secsUpdatedTag.map(({ id }) => id),
tx
);
const newSecretTags = secsUpdatedTag.flatMap(({ tags: secretTags = [], id }) =>
secretTags.map((tag) => ({
[`${TableName.SecretTag}Id`]: tag,
[`${TableName.Secret}Id`]: id
}))
);
await secretTagDal.saveTagsToSecret(newSecretTags, tx);
}
await secretVersionDal.insertMany(
newSecrets.map(({ id, createdAt, updatedAt, ...el }) => ({
...el,
secretId: id
})),
tx
);
return newSecrets;
};
const fnSecretBulkDelete = async ({
folderId,
inputSecrets,
tx,
actorId
}: TFnSecretBulkDelete) => {
const deletedSecrets = await secretDal.deleteMany(
inputSecrets.map(({ type, secretBlindIndex }) => ({
blindIndex: secretBlindIndex,
type
})),
folderId,
actorId,
tx
);
return deletedSecrets;
};
// this is a utility function for secret modification
// this will check given secret name blind index exist or not
// if its a created secret set isNew to true
// thus if these blindindex exist it will throw an error
// vice versa when u need to check for updated secret
// this will also return the blind index grouped by secretName
const fnSecretBlindIndexCheck = async ({
inputSecrets,
folderId,
isNew,
userId,
blindIndexCfg
}: TFnSecretBlindIndexCheck) => {
const blindIndex2KeyName: Record<string, string> = {}; // used at audit log point
const keyName2BlindIndex = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexCfg)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
blindIndex2KeyName[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) {
throw new BadRequestError({ message: "Missing user id for personal secret" });
}
const secrets = await secretDal.findByBlindIndexes(
folderId,
inputSecrets.map(({ secretName, type }) => ({
blindIndex: keyName2BlindIndex[secretName],
type: type || SecretType.Shared
})),
userId
);
if (isNew) {
if (secrets.length) throw new BadRequestError({ message: "Secret already exist" });
} else if (secrets.length !== inputSecrets.length)
throw new BadRequestError({ message: "Secret not found" });
return { blindIndex2KeyName, keyName2BlindIndex, secrets };
};
const createSecret = async ({
path,
actor,
@@ -104,27 +243,29 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const secretBlindIndex = await generateSecretBlindIndexByName(
projectId,
inputSecret.secretName
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
// check if secret exist by finding the secret blindIndex
const existingSecret = await secretDal.findOne({
secretBlindIndex,
const blindIndexCfg = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexCfg)
throw new BadRequestError({ message: "Blind index not found", name: "CreateSecret" });
if (ActorType.USER !== actor && inputSecret.type === SecretType.Personal) {
throw new BadRequestError({ message: "Must be user to create personal secret" });
}
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: [{ secretName: inputSecret.secretName }],
folderId,
type: inputSecret.type,
userId: inputSecret.type === SecretType.Personal ? actorId : null
isNew: true,
blindIndexCfg
});
if (existingSecret) throw new BadRequestError({ message: "Secret already exist" });
// if user creating personal check its shared also exist
if (inputSecret.type === SecretType.Personal) {
const sharedExist = await secretDal.findOne({
secretBlindIndex,
secretBlindIndex: keyName2BlindIndex[inputSecret.secretName],
folderId,
type: SecretType.Shared
});
@@ -142,46 +283,29 @@ export const secretServiceFactory = ({
if ((inputSecret.tags || []).length !== tags.length)
throw new BadRequestError({ message: "Tag not found" });
const secret = await secretDal.transaction(async (tx) => {
const { secretName, type, ...el } = inputSecret;
const doc = await secretDal.create(
{
version: 1,
folderId,
secretBlindIndex,
type,
...el,
userId: inputSecret.type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
},
const { secretName, type, ...el } = inputSecret;
const secret = await secretDal.transaction((tx) =>
fnSecretBulkInsert({
folderId,
inputSecrets: [
{
version: 1,
secretBlindIndex: keyName2BlindIndex[secretName],
type,
...el,
userId: inputSecret.type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8,
tags: inputSecret.tags
}
],
tx
);
if (tags.length) {
await secretTagDal.saveTagsToSecret(
tags.map(({ id }) => ({ secretsId: doc.id, secret_tagsId: id })),
tx
);
}
await secretVersionDal.create(
{
secretBlindIndex,
folderId,
version: 1,
type,
...el,
userId: inputSecret.type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8,
secretId: doc.id
},
tx
);
return doc;
});
})
);
await snapshotService.performSnapshot(folderId);
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return { ...secret, tags };
return { ...secret[0], tags };
};
const updateSecret = async ({
@@ -198,33 +322,38 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
const oldBlindIndex = await generateSecretBlindIndexBySalt(
inputSecret.secretName,
blindIndexDoc
);
if (!oldBlindIndex) throw new BadRequestError({ message: "Secret not found" });
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
let newSecretNameBlindIndex: string;
if (inputSecret?.newSecretName && inputSecret.type === SecretType.Shared) {
newSecretNameBlindIndex = await generateSecretBlindIndexBySalt(
inputSecret.newSecretName,
blindIndexDoc
);
const doesSecretExist = await secretDal.findOne({
secretBlindIndex: newSecretNameBlindIndex,
const blindIndexCfg = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexCfg)
throw new BadRequestError({ message: "Blind index not found", name: "CreateSecret" });
if (ActorType.USER !== actor && inputSecret.type === SecretType.Personal) {
throw new BadRequestError({ message: "Must be user to create personal secret" });
}
const { secrets, keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: [{ secretName: inputSecret.secretName, type: inputSecret.type as SecretType }],
folderId,
isNew: false,
blindIndexCfg,
userId: actorId
});
if (inputSecret.newSecretName && inputSecret.type === SecretType.Personal) {
throw new BadRequestError({ message: "Personal secret cannot change the key name" });
}
let newSecretNameBlindIndex: string | undefined;
if (inputSecret?.newSecretName) {
const { keyName2BlindIndex: kN2NewBlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: [{ secretName: inputSecret.newSecretName }],
folderId,
type: inputSecret.type
isNew: true,
blindIndexCfg
});
if (doesSecretExist) {
throw new BadRequestError({ message: "Secret with the provided name already exist" });
}
newSecretNameBlindIndex = kN2NewBlindIndex[inputSecret.newSecretName];
}
const tags = inputSecret.tags
@@ -233,41 +362,43 @@ export const secretServiceFactory = ({
if ((inputSecret.tags || []).length !== tags.length)
throw new BadRequestError({ message: "Tag not found" });
const updatedSecret = await secretDal.transaction(async (tx) => {
const { secretName, ...el } = inputSecret;
const [doc] = await secretDal.update(
{
secretBlindIndex: oldBlindIndex,
folderId,
type: inputSecret.type,
userId: inputSecret.type === SecretType.Personal ? actorId : null
},
{
secretBlindIndex: newSecretNameBlindIndex,
...el
},
const { secretName, ...el } = inputSecret;
const updatedSecret = await secretDal.transaction(async (tx) =>
fnSecretBulkUpdate({
folderId,
projectId,
inputSecrets: [
{
id: secrets[0].id,
version: (secrets[0].version || 0) + 1,
...pick(el, [
"type",
"secretCommentCiphertext",
"secretCommentTag",
"secretCommentIV",
"secretValueIV",
"secretValueTag",
"secretValueCiphertext",
"secretKeyCiphertext",
"secretKeyTag",
"secretKeyIV",
"metadata",
"skipMultilineEncoding",
"secretReminderNote",
"secretReminderRepeatDays",
"tags"
]),
secretBlindIndex: newSecretNameBlindIndex || keyName2BlindIndex[secretName]
}
],
tx
);
// replace tags
await secretTagDal.deleteTagsToSecret({ secretsId: doc.id }, tx);
await secretTagDal.saveTagsToSecret(
tags.map(({ id }) => ({ secretsId: doc.id, secret_tagsId: id })),
tx
);
const { id, createdAt, updatedAt, ...newVersion } = doc;
await secretVersionDal.create(
{
userId: inputSecret.type === SecretType.Personal ? actorId : null,
secretId: doc.id,
...newVersion
},
tx
);
return doc;
});
})
);
await snapshotService.performSnapshot(folderId);
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return updatedSecret;
return updatedSecret[0];
};
const deleteSecret = async ({
@@ -284,30 +415,44 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const secretBlindIndex = await generateSecretBlindIndexByName(
projectId,
inputSecret.secretName
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const deletedSecret = await secretDal.transaction(async (tx) => {
const [doc] = await secretDal.delete(
{
secretBlindIndex,
folderId,
type: inputSecret.type,
userId: inputSecret.type === SecretType.Personal ? actorId : null
},
tx
);
return doc;
const blindIndexCfg = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexCfg)
throw new BadRequestError({ message: "Blind index not found", name: "CreateSecret" });
if (ActorType.USER !== actor && inputSecret.type === SecretType.Personal) {
throw new BadRequestError({ message: "Must be user to create personal secret" });
}
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: [{ secretName: inputSecret.secretName }],
folderId,
isNew: false,
blindIndexCfg
});
const deletedSecret = await secretDal.transaction(async (tx) =>
fnSecretBulkDelete({
projectId,
folderId,
actorId,
inputSecrets: [
{
type: inputSecret.type as SecretType,
secretBlindIndex: keyName2BlindIndex[inputSecret.secretName]
}
],
tx
})
);
await snapshotService.performSnapshot(folderId);
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return deletedSecret;
return deletedSecret[0];
};
const getSecrets = async ({ actorId, path, environment, projectId, actor }: TGetSecretsDTO) => {
@@ -343,7 +488,7 @@ export const secretServiceFactory = ({
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const secretBlindIndex = await generateSecretBlindIndexByName(projectId, secretName);
const secretBlindIndex = await interalGenSecBlindIndexByName(projectId, secretName);
const secret = await secretDal.findOne({
folderId,
@@ -374,76 +519,38 @@ export const secretServiceFactory = ({
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
const blindIndexCfg = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexCfg)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
const secretBlindIndexes = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
secretBlindIndexToKey[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
const exists = await secretDal.findByBlindIndexes(
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets,
folderId,
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
}))
);
if (exists.length) throw new BadRequestError({ message: "Secret already exist" });
isNew: true,
blindIndexCfg
});
// get all tags
const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags);
const tags = tagIds.length ? await secretTagDal.findManyTagsById(projectId, tagIds) : [];
if (tags.length !== tagIds.length) throw new BadRequestError({ message: "Tag not found" });
const secrets = await secretDal.transaction(async (tx) => {
const newSecrets = await secretDal.insertMany(
inputSecrets.map(({ secretName, type, ...el }) => ({
version: 1,
folderId,
type,
secretBlindIndex: secretBlindIndexes[secretName],
const newSecrets = await secretDal.transaction(async (tx) =>
fnSecretBulkInsert({
inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({
...el,
userId: type === SecretType.Personal ? actorId : null,
secretBlindIndex: keyName2BlindIndex[secretName],
type: SecretType.Shared,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
})),
folderId,
tx
);
if (tags.length) {
await secretTagDal.saveTagsToSecret(
inputSecrets.flatMap(({ tags: secretTags = [], secretName }) => {
const secret = newSecrets.find(
({ secretBlindIndex }) => secretBlindIndexes[secretName] === secretBlindIndex
);
return secretTags.map((tag) => ({
[`${TableName.SecretTag}Id`]: tag,
[`${TableName.Secret}Id`]: secret?.id || ""
}));
}),
tx
);
}
await secretVersionDal.insertMany(
newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
})),
tx
);
})
);
return newSecrets;
});
return secrets;
await snapshotService.performSnapshot(folderId);
return newSecrets;
};
const updateManySecret = async ({
@@ -464,121 +571,58 @@ export const secretServiceFactory = ({
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
const blindIndexCfg = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexCfg)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
// get all blind index
// Find all those secrets
// if not throw not found
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
const secretBlindIndexes = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
secretBlindIndexToKey[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
const secretsToBeUpdated = await secretDal.findByBlindIndexes(
const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await fnSecretBlindIndexCheck({
inputSecrets,
folderId,
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
}))
);
if (secretsToBeUpdated.length !== inputSecrets.length)
throw new BadRequestError({ message: "Secret not found" });
isNew: false,
blindIndexCfg
});
// now find any secret that needs to update its name
// same process as above
const nameUpdatedSecrets = inputSecrets.filter(({ newSecretName }) => Boolean(newSecretName));
const newSecretBlindIndexes = await Promise.all(
nameUpdatedSecrets.map(({ newSecretName }) =>
generateSecretBlindIndexBySalt(newSecretName as string, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[nameUpdatedSecrets[i].secretName] = curr;
return prev;
}, {})
);
const secretsWithNewName = await secretDal.findByBlindIndexes(
const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: nameUpdatedSecrets,
folderId,
nameUpdatedSecrets.map(({ type, newSecretName }) => ({
blindIndex: newSecretBlindIndexes[newSecretName as string],
type
}))
);
if (secretsWithNewName.length)
throw new BadRequestError({ message: "Secret with new name already exist" });
const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce<Record<string, TSecrets>>(
(prev, curr) => {
// eslint-disable-next-line
if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr;
return prev;
},
{}
);
isNew: true,
blindIndexCfg
});
const secsGroupedByBlindIndex = groupBy(secretsToBeUpdated, (el) => el.secretBlindIndex);
// get all tags
const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags);
const tags = tagIds.length ? await secretTagDal.findManyTagsById(projectId, tagIds) : [];
if (tagIds.length !== tags.length) throw new BadRequestError({ message: "Tag not found" });
const secrets = await secretDal.transaction(async (tx) => {
const newSecrets = await secretDal.bulkUpdate(
inputSecrets.map(({ secretName, type, ...el }) => {
const secrets = await secretDal.transaction(async (tx) =>
fnSecretBulkUpdate({
folderId,
projectId,
tx,
inputSecrets: inputSecrets.map(({ secretName, newSecretName, ...el }) => {
const { version, updatedAt, ...info } =
secretsGroupedByBlindIndex[secretBlindIndexes[secretName]];
secsGroupedByBlindIndex[keyName2BlindIndex[secretName]][0];
return {
...el,
version: (version || 0) + 1,
...info,
folderId,
type,
type: SecretType.Shared,
secretBlindIndex:
el?.newSecretName && newSecretBlindIndexes[el.newSecretName]
? newSecretBlindIndexes[el.newSecretName]
: secretBlindIndexes[secretName],
...el,
userId: type === SecretType.Personal ? actorId : null,
newSecretName && newKeyName2BlindIndex[newSecretName]
? newKeyName2BlindIndex[newSecretName]
: keyName2BlindIndex[secretName],
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
};
}),
tx
);
await secretTagDal.deleteTagsManySecret(
projectId,
newSecrets.map(({ id }) => id),
tx
);
await secretTagDal.saveTagsToSecret(
inputSecrets.flatMap(({ secretName, tags: secretTags = [] }) =>
secretTags.map((secretTag) => ({
[`${TableName.Secret}Id`]: secretsGroupedByBlindIndex[secretName].id,
[`${TableName.SecretTag}Id`]: secretTag
}))
),
tx
);
await secretVersionDal.insertMany(
newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
})),
tx
);
})
})
);
return newSecrets;
});
await snapshotService.performSnapshot(folderId);
return secrets;
};
@@ -600,51 +644,63 @@ export const secretServiceFactory = ({
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
const blindIndexCfg = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexCfg)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
// get all blind index
// Find all those secrets
// if not throw not found
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
const secretBlindIndexes = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
secretBlindIndexToKey[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
// not find those secrets. if any of them not found throw an not found error
const secretsToBeDeleted = await secretDal.findByBlindIndexes(
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets,
folderId,
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
}))
);
if (secretsToBeDeleted.length !== inputSecrets.length)
throw new BadRequestError({ message: "Secret not found" });
isNew: false,
blindIndexCfg
});
const secretsDeleted = await secretDal.transaction(async (tx) =>
secretDal.deleteMany(
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
fnSecretBulkDelete({
inputSecrets: inputSecrets.map(({ type, secretName }) => ({
secretBlindIndex: keyName2BlindIndex[secretName],
type
})),
projectId,
folderId,
actorId,
tx
)
})
);
await snapshotService.performSnapshot(folderId);
return secretsDeleted;
};
const listSecretVersionsBySecretId = async ({
actorId,
actor,
limit,
offset,
secretId
}: TListSecretVersionDTO) => {
const secret = await secretDal.findById(secretId);
if (!secret) throw new BadRequestError({ message: "Failed to find secret" });
const folder = await folderDal.findById(secret.folderId);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
folder.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
);
const secretVersions = await secretVersionDal.find(
{ secretId },
{ limit, offset, sort: [["createdAt", "desc"]] }
);
return secretVersions;
};
return {
createSecret,
deleteSecret,
@@ -653,6 +709,12 @@ export const secretServiceFactory = ({
updateManySecret,
deleteManySecret,
getASecret,
getSecrets
getSecrets,
listSecretVersionsBySecretId,
// external services function
fnSecretBulkDelete,
fnSecretBulkUpdate,
fnSecretBlindIndexCheck,
fnSecretBulkInsert
};
};

View File

@@ -1,4 +1,6 @@
import { SecretType } from "@app/db/schemas";
import { Knex } from "knex";
import { SecretType, TSecretBlindIndexes, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
export type TCreateSecretDTO = {
@@ -73,7 +75,6 @@ export type TCreateBulkSecretDTO = {
environment: string;
secrets: Array<{
secretName: string;
type: SecretType;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
@@ -117,3 +118,38 @@ export type TDeleteBulkSecretDTO = {
secretName: string;
}>;
} & TProjectPermission;
export type TListSecretVersionDTO = {
secretId: string;
offset?: number;
limit?: number;
} & Omit<TProjectPermission, "projectId">;
export type TFnSecretBulkInsert = {
folderId: string;
tx?: Knex;
inputSecrets: Array<Omit<TSecretsInsert, "folderId"> & { tags?: string[] }>;
};
export type TFnSecretBulkUpdate = {
folderId: string;
projectId: string;
inputSecrets: Array<TSecretsUpdate & { tags?: string[]; id: string }>;
tx?: Knex;
};
export type TFnSecretBulkDelete = {
folderId: string;
projectId: string;
inputSecrets: Array<{ type: SecretType; secretBlindIndex: string }>;
actorId: string;
tx?: Knex;
};
export type TFnSecretBlindIndexCheck = {
folderId: string;
userId?: string;
blindIndexCfg: TSecretBlindIndexes;
inputSecrets: Array<{ secretName: string; type?: SecretType }>;
isNew: boolean;
};

View File

@@ -1,15 +1,41 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TSecretVersions } from "@app/db/schemas";
import { TableName, TSecretVersions } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TSecretVersionDalFactory = ReturnType<typeof secretVersionDalFactory>;
export const secretVersionDalFactory = (db: TDbClient) => {
const secretVersionOrm = ormify(db, TableName.SecretVersion);
// This will fetch all latest secret versions from a folder
const findLatestVersionByFolderId = async (folderId: string, tx?: Knex) => {
try {
const docs = await (tx || db)(TableName.SecretVersion)
.where(`${TableName.SecretVersion}.folderId`, folderId)
.join(TableName.Secret, `${TableName.Secret}.id`, `${TableName.SecretVersion}.secretId`)
.join<TSecretVersions, TSecretVersions & { secretId: string; max: number }>(
(tx || db)(TableName.SecretVersion)
.groupBy("folderId", "secretId")
.max("version")
.select("secretId")
.as("latestVersion"),
(bd) => {
bd.on(`${TableName.SecretVersion}.secretId`, "latestVersion.secretId").andOn(
`${TableName.SecretVersion}.version`,
"latestVersion.max"
);
}
)
.select(selectAllTableCols(TableName.SecretVersion));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindLatestVersionByFolderId" });
}
};
const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => {
try {
const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(
@@ -31,7 +57,7 @@ export const secretVersionDalFactory = (db: TDbClient) => {
}
);
return docs.reduce<Record<string, TSecretVersions>>(
(prev, curr) => ({ ...prev, [curr.secretId]: curr }),
(prev, curr) => ({ ...prev, [curr.secretId || ""]: curr }),
{}
);
} catch (error) {
@@ -39,5 +65,5 @@ export const secretVersionDalFactory = (db: TDbClient) => {
}
};
return { ...secretVersionOrm, findLatestVersionMany };
return { ...secretVersionOrm, findLatestVersionMany, findLatestVersionByFolderId };
};

View File

@@ -24,6 +24,16 @@ services:
POSTGRES_USER: infisical
POSTGRES_DB: infisical
redis:
image: redis
container_name: infisical-dev-redis
environment:
- ALLOW_EMPTY_PASSWORD=yes
ports:
- 6379:6379
volumes:
- redis_data:/data
db-test:
profiles: ["test"]
image: postgres:14-alpine
@@ -67,3 +77,5 @@ services:
volumes:
postgres-data:
driver: local
redis_data:
driver: local

View File

@@ -19,7 +19,7 @@ import {
TUpdateFolderDTO
} from "./types";
const queryKeys = {
export const folderQueryKeys = {
getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) =>
["secret-folders", { projectId, environment, path }] as const
};
@@ -46,14 +46,14 @@ export const useGetProjectFolders = ({
TSecretFolder[],
unknown,
TSecretFolder[],
ReturnType<typeof queryKeys.getSecretFolders>
ReturnType<typeof folderQueryKeys.getSecretFolders>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
...options,
queryKey: queryKeys.getSecretFolders({ projectId, environment, path }),
queryKey: folderQueryKeys.getSecretFolders({ projectId, environment, path }),
enabled: Boolean(projectId) && Boolean(environment) && (options?.enabled ?? true),
queryFn: async () => fetchProjectFolders(projectId, environment, path)
});
@@ -65,7 +65,7 @@ export const useGetFoldersByEnv = ({
}: TGetFoldersByEnvDTO) => {
const folders = useQueries({
queries: environments.map((environment) => ({
queryKey: queryKeys.getSecretFolders({ projectId, environment, path }),
queryKey: folderQueryKeys.getSecretFolders({ projectId, environment, path }),
queryFn: async () => fetchProjectFolders(projectId, environment, path),
enabled: Boolean(projectId) && Boolean(environment)
}))
@@ -106,7 +106,9 @@ export const useCreateFolder = () => {
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path }));
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path })
);
@@ -131,7 +133,9 @@ export const useUpdateFolder = () => {
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path }));
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path })
);
@@ -157,7 +161,9 @@ export const useDeleteFolder = () => {
return data;
},
onSuccess: (_, { path = "/", projectId, environment }) => {
queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path }));
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path })
);

View File

@@ -35,13 +35,13 @@ const fetchWorkspaceSnaphots = async ({
offset = 0
}: TGetSecretSnapshotsDTO & { offset: number }) => {
const res = await apiRequest.get<{ secretSnapshots: TSecretSnapshot[] }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots`,
`/api/ee/v1/workspace/${workspaceId}/secret-snapshots`,
{
params: {
limit,
offset,
environment,
directory
path: directory
}
}
);
@@ -60,12 +60,12 @@ export const useGetWorkspaceSnapshotList = (dto: TGetSecretSnapshotsDTO & { isPa
const fetchSnapshotEncSecrets = async (snapshotId: string) => {
const res = await apiRequest.get<{ secretSnapshot: TSnapshotData }>(
`/api/v1/secret-snapshot/${snapshotId}`
`/api/ee/v1/secret-snapshot/${snapshotId}`
);
return res.data.secretSnapshot;
};
export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnapshotDataProps) =>
export const useGetSnapshotSecrets = ({ decryptFileKey, snapshotId }: TSnapshotDataProps) =>
useQuery({
queryKey: secretSnapshotKeys.snapshotData(snapshotId),
enabled: Boolean(snapshotId && decryptFileKey),
@@ -82,45 +82,43 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap
const sharedSecrets: DecryptedSecret[] = [];
const personalSecrets: Record<string, { id: string; value: string }> = {};
data.secretVersions
.filter(({ environment }) => environment === env)
.forEach((encSecret) => {
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
});
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
});
const secretComment = "";
const decryptedSecret = {
id: encSecret.secret,
env: encSecret.environment,
key: secretKey,
value: secretValue,
tags: encSecret.tags,
comment: secretComment,
createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt,
type: "modified",
version: encSecret.version
};
if (encSecret.type === "personal") {
personalSecrets[decryptedSecret.key] = { id: encSecret.secret, value: secretValue };
} else {
sharedSecrets.push(decryptedSecret);
}
data.secretVersions.forEach((encSecret) => {
const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext,
iv: encSecret.secretKeyIV,
tag: encSecret.secretKeyTag,
key
});
const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext,
iv: encSecret.secretValueIV,
tag: encSecret.secretValueTag,
key
});
const secretComment = "";
const decryptedSecret = {
id: encSecret.secretId,
env: data.environment.slug,
key: secretKey,
value: secretValue,
tags: encSecret.tags,
comment: secretComment,
createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt,
type: "modified",
version: encSecret.version
};
if (encSecret.type === "personal") {
personalSecrets[decryptedSecret.key] = { id: encSecret.secretId, value: secretValue };
} else {
sharedSecrets.push(decryptedSecret);
}
});
sharedSecrets.forEach((val) => {
if (personalSecrets?.[val.key]) {
val.idOverride = personalSecrets[val.key].id;
@@ -130,7 +128,7 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap
});
return {
version: data.version,
id: data.id,
secrets: sharedSecrets,
createdAt: data.createdAt,
folders: data.folderVersion
@@ -144,11 +142,11 @@ const fetchWorkspaceSecretSnaphotCount = async (
directory = "/"
) => {
const res = await apiRequest.get<{ count: number }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots/count`,
`/api/ee/v1/workspace/${workspaceId}/secret-snapshots/count`,
{
params: {
environment,
directory
path: directory
}
}
);
@@ -171,11 +169,8 @@ export const usePerformSecretRollback = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TSecretRollbackDTO>({
mutationFn: async ({ workspaceId, ...dto }) => {
const { data } = await apiRequest.post(
`/api/v1/workspace/${workspaceId}/secret-snapshots/rollback`,
dto
);
mutationFn: async ({ snapshotId }) => {
const { data } = await apiRequest.post(`/api/ee/v1/secret-snapshot/${snapshotId}/rollback`);
return data;
},
onSuccess: (_, { workspaceId, environment, directory }) => {
@@ -183,6 +178,10 @@ export const usePerformSecretRollback = () => {
{ workspaceId, environment, secretPath: directory },
"secrets"
]);
queryClient.invalidateQueries([
"secret-folders",
{ projectId: workspaceId, environment, path: directory }
]);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId, environment, directory })
);

View File

@@ -1,19 +1,20 @@
import { UserWsKeyPair } from "../keys/types";
import { EncryptedSecretVersion } from "../secrets/types";
import { WorkspaceEnv } from "../types";
export type TSecretSnapshot = {
id: string;
workspace: string;
version: number;
secretVersions: string[];
createdAt: string;
updatedAt: string;
__v: number;
};
export type TSnapshotData = Omit<TSecretSnapshot, "secretVersions"> & {
id: string;
secretVersions: EncryptedSecretVersion[];
folderVersion: Array<{ name: string; id: string }>;
environment: WorkspaceEnv;
};
export type TSnapshotDataProps = {
@@ -30,8 +31,8 @@ export type TGetSecretSnapshotsDTO = {
};
export type TSecretRollbackDTO = {
snapshotId: string;
workspaceId: string;
version: number;
environment: string;
directory?: string;
};

View File

@@ -46,12 +46,12 @@ export type DecryptedSecret = {
export type EncryptedSecretVersion = {
id: string;
secret: string;
secretId: string;
version: number;
workspace: string;
type: string;
environment: string;
isDeleted: boolean;
envId: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;

View File

@@ -97,6 +97,7 @@ export const SnapshotView = ({
{}
);
const diffView: Array<TDiffView<DecryptedSecret>> = [];
console.log({ rollingSecrets, secrets });
rollingSecrets.forEach((rollSecret) => {
const { id } = rollSecret;
const doesExist = Boolean(secretGroupById?.[id]);
@@ -118,9 +119,9 @@ export const SnapshotView = ({
});
return diffView;
}, [secrets, rollingSecrets]);
console.log(secretDiffView);
const handleClickRollback = async () => {
if (!snapshotData?.version) {
if (!snapshotData?.id) {
createNotification({
text: "Failed to find secret version",
type: "success"
@@ -130,7 +131,7 @@ export const SnapshotView = ({
try {
await performRollback({
workspaceId,
version: snapshotData.version,
snapshotId: snapshotData.id,
environment,
directory: secretPath
});