Fix merge conflicts

This commit is contained in:
Tuan Dang
2024-06-06 23:20:09 -04:00
102 changed files with 3183 additions and 684 deletions
@@ -40,13 +40,14 @@ jobs:
REDIS_URL: redis://172.17.0.1:6379 REDIS_URL: redis://172.17.0.1:6379
DB_CONNECTION_URI: postgres://infisical:[email protected]:5432/infisical?sslmode=disable DB_CONNECTION_URI: postgres://infisical:[email protected]:5432/infisical?sslmode=disable
JWT_AUTH_SECRET: something-random JWT_AUTH_SECRET: something-random
ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.21.5' go-version: '1.21.5'
- name: Wait for container to be stable and check logs - name: Wait for container to be stable and check logs
run: | run: |
SECONDS=0 SECONDS=0
HEALTHY=0 r HEALTHY=0
while [ $SECONDS -lt 60 ]; do while [ $SECONDS -lt 60 ]; do
if docker ps | grep infisical-api | grep -q healthy; then if docker ps | grep infisical-api | grep -q healthy; then
echo "Container is healthy." echo "Container is healthy."
@@ -73,4 +74,4 @@ jobs:
run: | run: |
docker-compose -f "docker-compose.dev.yml" down docker-compose -f "docker-compose.dev.yml" down
docker stop infisical-api docker stop infisical-api
docker remove infisical-api docker remove infisical-api
+8 -1
View File
@@ -1,4 +1,5 @@
import { TKeyStoreFactory } from "@app/keystore/keystore"; import { TKeyStoreFactory } from "@app/keystore/keystore";
import { Lock } from "@app/lib/red-lock";
export const mockKeyStore = (): TKeyStoreFactory => { export const mockKeyStore = (): TKeyStoreFactory => {
const store: Record<string, string | number | Buffer> = {}; const store: Record<string, string | number | Buffer> = {};
@@ -25,6 +26,12 @@ export const mockKeyStore = (): TKeyStoreFactory => {
}, },
incrementBy: async () => { incrementBy: async () => {
return 1; return 1;
} },
acquireLock: () => {
return Promise.resolve({
release: () => {}
}) as Promise<Lock>;
},
waitTillReady: async () => {}
}; };
}; };
+5 -4
View File
@@ -53,7 +53,7 @@
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"ms": "^2.1.3", "ms": "^2.1.3",
"mysql2": "^3.9.7", "mysql2": "^3.9.8",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
"ora": "^7.0.1", "ora": "^7.0.1",
@@ -10448,9 +10448,10 @@
} }
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.9.7", "version": "3.9.8",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.8.tgz",
"integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "integrity": "sha512-+5JKNjPuks1FNMoy9TYpl77f+5frbTklz7eb3XDwbpsERRLEeXiW2PDEkakYF50UuKU2qwfGnyXpKYvukv8mGA==",
"license": "MIT",
"dependencies": { "dependencies": {
"denque": "^2.1.0", "denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",
+1 -1
View File
@@ -114,7 +114,7 @@
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"ms": "^2.1.3", "ms": "^2.1.3",
"mysql2": "^3.9.7", "mysql2": "^3.9.8",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
"ora": "^7.0.1", "ora": "^7.0.1",
+11
View File
@@ -35,6 +35,8 @@ const getZodPrimitiveType = (type: string) => {
return "z.coerce.number()"; return "z.coerce.number()";
case "text": case "text":
return "z.string()"; return "z.string()";
case "bytea":
return "zodBuffer";
default: default:
throw new Error(`Invalid type: ${type}`); throw new Error(`Invalid type: ${type}`);
} }
@@ -96,10 +98,15 @@ const main = async () => {
const columnNames = Object.keys(columns); const columnNames = Object.keys(columns);
let schema = ""; let schema = "";
const zodImportSet = new Set<string>();
for (let colNum = 0; colNum < columnNames.length; colNum++) { for (let colNum = 0; colNum < columnNames.length; colNum++) {
const columnName = columnNames[colNum]; const columnName = columnNames[colNum];
const colInfo = columns[columnName]; const colInfo = columns[columnName];
let ztype = getZodPrimitiveType(colInfo.type); let ztype = getZodPrimitiveType(colInfo.type);
if (["zodBuffer"].includes(ztype)) {
zodImportSet.add(ztype);
}
// don't put optional on id // don't put optional on id
if (colInfo.defaultValue && columnName !== "id") { if (colInfo.defaultValue && columnName !== "id") {
const { defaultValue } = colInfo; const { defaultValue } = colInfo;
@@ -121,6 +128,8 @@ const main = async () => {
.split("_") .split("_")
.reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, ""); .reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, "");
const zodImports = Array.from(zodImportSet);
// the insert and update are changed to zod input type to use default cases // the insert and update are changed to zod input type to use default cases
writeFileSync( writeFileSync(
path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`), path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`),
@@ -131,6 +140,8 @@ const main = async () => {
import { z } from "zod"; import { z } from "zod";
${zodImports.length ? `import { ${zodImports.join(",")} } from \"@app/lib/zod\";` : ""}
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const ${pascalCase}Schema = z.object({${schema}}); export const ${pascalCase}Schema = z.object({${schema}});
+2
View File
@@ -54,6 +54,7 @@ import { TSecretServiceFactory } from "@app/services/secret/secret-service";
import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service";
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service";
import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service"; import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
@@ -110,6 +111,7 @@ declare module "fastify" {
projectKey: TProjectKeyServiceFactory; projectKey: TProjectKeyServiceFactory;
projectRole: TProjectRoleServiceFactory; projectRole: TProjectRoleServiceFactory;
secret: TSecretServiceFactory; secret: TSecretServiceFactory;
secretReplication: TSecretReplicationServiceFactory;
secretTag: TSecretTagServiceFactory; secretTag: TSecretTagServiceFactory;
secretImport: TSecretImportServiceFactory; secretImport: TSecretImportServiceFactory;
projectBot: TProjectBotServiceFactory; projectBot: TProjectBotServiceFactory;
+20 -1
View File
@@ -119,6 +119,15 @@ import {
TIntegrations, TIntegrations,
TIntegrationsInsert, TIntegrationsInsert,
TIntegrationsUpdate, TIntegrationsUpdate,
TKmsKeys,
TKmsKeysInsert,
TKmsKeysUpdate,
TKmsKeyVersions,
TKmsKeyVersionsInsert,
TKmsKeyVersionsUpdate,
TKmsRootConfig,
TKmsRootConfigInsert,
TKmsRootConfigUpdate,
TLdapConfigs, TLdapConfigs,
TLdapConfigsInsert, TLdapConfigsInsert,
TLdapConfigsUpdate, TLdapConfigsUpdate,
@@ -197,6 +206,9 @@ import {
TSecretImports, TSecretImports,
TSecretImportsInsert, TSecretImportsInsert,
TSecretImportsUpdate, TSecretImportsUpdate,
TSecretReferences,
TSecretReferencesInsert,
TSecretReferencesUpdate,
TSecretRotationOutputs, TSecretRotationOutputs,
TSecretRotationOutputsInsert, TSecretRotationOutputsInsert,
TSecretRotationOutputsUpdate, TSecretRotationOutputsUpdate,
@@ -261,7 +273,6 @@ import {
TWebhooksInsert, TWebhooksInsert,
TWebhooksUpdate TWebhooksUpdate
} from "@app/db/schemas"; } from "@app/db/schemas";
import { TSecretReferences, TSecretReferencesInsert, TSecretReferencesUpdate } from "@app/db/schemas/secret-references";
declare module "knex/types/tables" { declare module "knex/types/tables" {
interface Tables { interface Tables {
@@ -566,5 +577,13 @@ declare module "knex/types/tables" {
TSecretVersionTagJunctionInsert, TSecretVersionTagJunctionInsert,
TSecretVersionTagJunctionUpdate TSecretVersionTagJunctionUpdate
>; >;
// KMS service
[TableName.KmsServerRootConfig]: Knex.CompositeTableType<
TKmsRootConfig,
TKmsRootConfigInsert,
TKmsRootConfigUpdate
>;
[TableName.KmsKey]: Knex.CompositeTableType<TKmsKeys, TKmsKeysInsert, TKmsKeysUpdate>;
[TableName.KmsKeyVersion]: Knex.CompositeTableType<TKmsKeyVersions, TKmsKeyVersionsInsert, TKmsKeyVersionsUpdate>;
} }
} }
@@ -0,0 +1,33 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (!hasExpiresAfterViewsColumn) {
t.integer("expiresAfterViews");
}
if (hasSecretNameColumn) {
t.dropColumn("name");
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasExpiresAfterViewsColumn) {
t.dropColumn("expiresAfterViews");
}
if (!hasSecretNameColumn) {
t.string("name").notNullable();
}
});
}
@@ -0,0 +1,85 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication");
const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn(
TableName.SecretImport,
"isReplicationSuccess"
);
const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn(
TableName.SecretImport,
"replicationStatus"
);
const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated");
const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved");
if (await knex.schema.hasTable(TableName.SecretImport)) {
await knex.schema.alterTable(TableName.SecretImport, (t) => {
if (!doesSecretImportIsReplicationExist) t.boolean("isReplication").defaultTo(false);
if (!doesSecretImportIsReplicationSuccessExist) t.boolean("isReplicationSuccess").nullable();
if (!doesSecretImportReplicationStatusExist) t.text("replicationStatus").nullable();
if (!doesSecretImportLastReplicatedExist) t.datetime("lastReplicated").nullable();
if (!doesSecretImportIsReservedExist) t.boolean("isReserved").defaultTo(false);
});
}
const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved");
if (await knex.schema.hasTable(TableName.SecretFolder)) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
if (!doesSecretFolderReservedExist) t.boolean("isReserved").defaultTo(false);
});
}
const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn(
TableName.SecretApprovalRequest,
"isReplicated"
);
if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) {
await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => {
if (!doesSecretApprovalRequestIsReplicatedExist) t.boolean("isReplicated");
});
}
}
export async function down(knex: Knex): Promise<void> {
const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication");
const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn(
TableName.SecretImport,
"isReplicationSuccess"
);
const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn(
TableName.SecretImport,
"replicationStatus"
);
const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated");
const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved");
if (await knex.schema.hasTable(TableName.SecretImport)) {
await knex.schema.alterTable(TableName.SecretImport, (t) => {
if (doesSecretImportIsReplicationExist) t.dropColumn("isReplication");
if (doesSecretImportIsReplicationSuccessExist) t.dropColumn("isReplicationSuccess");
if (doesSecretImportReplicationStatusExist) t.dropColumn("replicationStatus");
if (doesSecretImportLastReplicatedExist) t.dropColumn("lastReplicated");
if (doesSecretImportIsReservedExist) t.dropColumn("isReserved");
});
}
const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved");
if (await knex.schema.hasTable(TableName.SecretFolder)) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
if (doesSecretFolderReservedExist) t.dropColumn("isReserved");
});
}
const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn(
TableName.SecretApprovalRequest,
"isReplicated"
);
if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) {
await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => {
if (doesSecretApprovalRequestIsReplicatedExist) t.dropColumn("isReplicated");
});
}
}
@@ -0,0 +1,56 @@
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.KmsServerRootConfig))) {
await knex.schema.createTable(TableName.KmsServerRootConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.binary("encryptedRootKey").notNullable();
});
}
await createOnUpdateTrigger(knex, TableName.KmsServerRootConfig);
if (!(await knex.schema.hasTable(TableName.KmsKey))) {
await knex.schema.createTable(TableName.KmsKey, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.binary("encryptedKey").notNullable();
t.string("encryptionAlgorithm").notNullable();
t.integer("version").defaultTo(1).notNullable();
t.string("description");
t.boolean("isDisabled").defaultTo(false);
t.boolean("isReserved").defaultTo(true);
t.string("projectId");
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("orgId");
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.KmsKey);
if (!(await knex.schema.hasTable(TableName.KmsKeyVersion))) {
await knex.schema.createTable(TableName.KmsKeyVersion, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.binary("encryptedKey").notNullable();
t.integer("version").notNullable();
t.uuid("kmsKeyId").notNullable();
t.foreign("kmsKeyId").references("id").inTable(TableName.KmsKey).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.KmsKeyVersion);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.KmsServerRootConfig);
await dropOnUpdateTrigger(knex, TableName.KmsServerRootConfig);
await knex.schema.dropTableIfExists(TableName.KmsKeyVersion);
await dropOnUpdateTrigger(knex, TableName.KmsKeyVersion);
await knex.schema.dropTableIfExists(TableName.KmsKey);
await dropOnUpdateTrigger(knex, TableName.KmsKey);
}
+4
View File
@@ -37,6 +37,9 @@ export * from "./identity-universal-auths";
export * from "./incident-contacts"; export * from "./incident-contacts";
export * from "./integration-auths"; export * from "./integration-auths";
export * from "./integrations"; export * from "./integrations";
export * from "./kms-key-versions";
export * from "./kms-keys";
export * from "./kms-root-config";
export * from "./ldap-configs"; export * from "./ldap-configs";
export * from "./ldap-group-maps"; export * from "./ldap-group-maps";
export * from "./models"; export * from "./models";
@@ -64,6 +67,7 @@ export * from "./secret-blind-indexes";
export * from "./secret-folder-versions"; export * from "./secret-folder-versions";
export * from "./secret-folders"; export * from "./secret-folders";
export * from "./secret-imports"; export * from "./secret-imports";
export * from "./secret-references";
export * from "./secret-rotation-outputs"; export * from "./secret-rotation-outputs";
export * from "./secret-rotations"; export * from "./secret-rotations";
export * from "./secret-scanning-git-risks"; export * from "./secret-scanning-git-risks";
@@ -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 { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const KmsKeyVersionsSchema = z.object({
id: z.string().uuid(),
encryptedKey: zodBuffer,
version: z.number(),
kmsKeyId: z.string().uuid()
});
export type TKmsKeyVersions = z.infer<typeof KmsKeyVersionsSchema>;
export type TKmsKeyVersionsInsert = Omit<z.input<typeof KmsKeyVersionsSchema>, TImmutableDBKeys>;
export type TKmsKeyVersionsUpdate = Partial<Omit<z.input<typeof KmsKeyVersionsSchema>, TImmutableDBKeys>>;
+26
View File
@@ -0,0 +1,26 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const KmsKeysSchema = z.object({
id: z.string().uuid(),
encryptedKey: zodBuffer,
encryptionAlgorithm: z.string(),
version: z.number().default(1),
description: z.string().nullable().optional(),
isDisabled: z.boolean().default(false).nullable().optional(),
isReserved: z.boolean().default(true).nullable().optional(),
projectId: z.string().nullable().optional(),
orgId: z.string().uuid().nullable().optional()
});
export type TKmsKeys = z.infer<typeof KmsKeysSchema>;
export type TKmsKeysInsert = Omit<z.input<typeof KmsKeysSchema>, TImmutableDBKeys>;
export type TKmsKeysUpdate = Partial<Omit<z.input<typeof KmsKeysSchema>, TImmutableDBKeys>>;
+19
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 { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const KmsRootConfigSchema = z.object({
id: z.string().uuid(),
encryptedRootKey: zodBuffer
});
export type TKmsRootConfig = z.infer<typeof KmsRootConfigSchema>;
export type TKmsRootConfigInsert = Omit<z.input<typeof KmsRootConfigSchema>, TImmutableDBKeys>;
export type TKmsRootConfigUpdate = Partial<Omit<z.input<typeof KmsRootConfigSchema>, TImmutableDBKeys>>;
+5 -1
View File
@@ -88,7 +88,11 @@ export enum TableName {
DynamicSecretLease = "dynamic_secret_leases", DynamicSecretLease = "dynamic_secret_leases",
// junction tables with tags // junction tables with tags
JnSecretTag = "secret_tag_junction", JnSecretTag = "secret_tag_junction",
SecretVersionTag = "secret_version_tag_junction" SecretVersionTag = "secret_version_tag_junction",
// KMS Service
KmsServerRootConfig = "kms_root_config",
KmsKey = "kms_keys",
KmsKeyVersion = "kms_key_versions"
} }
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
@@ -18,7 +18,8 @@ export const SecretApprovalRequestsSchema = z.object({
statusChangeBy: z.string().uuid().nullable().optional(), statusChangeBy: z.string().uuid().nullable().optional(),
committerId: z.string().uuid(), committerId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
isReplicated: z.boolean().nullable().optional()
}); });
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>; export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;
+2 -1
View File
@@ -14,7 +14,8 @@ export const SecretFoldersSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
envId: z.string().uuid(), envId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional() parentId: z.string().uuid().nullable().optional(),
isReserved: z.boolean().default(false).nullable().optional()
}); });
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>; export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;
+6 -1
View File
@@ -15,7 +15,12 @@ export const SecretImportsSchema = z.object({
position: z.number(), position: z.number(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
folderId: z.string().uuid() folderId: z.string().uuid(),
isReplication: z.boolean().default(false).nullable().optional(),
isReplicationSuccess: z.boolean().nullable().optional(),
replicationStatus: z.string().nullable().optional(),
lastReplicated: z.date().nullable().optional(),
isReserved: z.boolean().default(false).nullable().optional()
}); });
export type TSecretImports = z.infer<typeof SecretImportsSchema>; export type TSecretImports = z.infer<typeof SecretImportsSchema>;
+2 -2
View File
@@ -9,7 +9,6 @@ import { TImmutableDBKeys } from "./models";
export const SecretSharingSchema = z.object({ export const SecretSharingSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string(),
encryptedValue: z.string(), encryptedValue: z.string(),
iv: z.string(), iv: z.string(),
tag: z.string(), tag: z.string(),
@@ -18,7 +17,8 @@ export const SecretSharingSchema = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
expiresAfterViews: z.number().nullable().optional()
}); });
export type TSecretSharing = z.infer<typeof SecretSharingSchema>; export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
@@ -32,22 +32,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
}), }),
response: { response: {
200: z.object({ 200: z.object({
approvals: SecretApprovalRequestsSchema.merge( approvals: SecretApprovalRequestsSchema.extend({
z.object({ // secretPath: z.string(),
// secretPath: z.string(), policy: z.object({
policy: z.object({ id: z.string(),
id: z.string(), name: z.string(),
name: z.string(), approvals: z.number(),
approvals: z.number(), approvers: z.string().array(),
approvers: z.string().array(), secretPath: z.string().optional().nullable()
secretPath: z.string().optional().nullable() }),
}), commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), environment: z.string(),
environment: z.string(), reviewers: z.object({ member: z.string(), status: z.string() }).array(),
reviewers: z.object({ member: z.string(), status: z.string() }).array(), approvers: z.string().array()
approvers: z.string().array() }).array()
})
).array()
}) })
} }
}, },
@@ -15,9 +15,16 @@ import { ActorType } from "@app/services/auth/auth-type";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TSecretDALFactory } from "@app/services/secret/secret-dal"; import { TSecretDALFactory } from "@app/services/secret/secret-dal";
import { getAllNestedSecretReferences } from "@app/services/secret/secret-fns"; import {
fnSecretBlindIndexCheck,
fnSecretBlindIndexCheckV2,
fnSecretBulkDelete,
fnSecretBulkInsert,
fnSecretBulkUpdate,
getAllNestedSecretReferences
} from "@app/services/secret/secret-fns";
import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue";
import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { SecretOperations } from "@app/services/secret/secret-types";
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
@@ -32,7 +39,6 @@ import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-requ
import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal";
import { import {
ApprovalStatus, ApprovalStatus,
CommitType,
RequestState, RequestState,
TApprovalRequestCountDTO, TApprovalRequestCountDTO,
TGenerateSecretApprovalRequestDTO, TGenerateSecretApprovalRequestDTO,
@@ -45,10 +51,11 @@ import {
type TSecretApprovalRequestServiceFactoryDep = { type TSecretApprovalRequestServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
secretApprovalRequestDAL: TSecretApprovalRequestDALFactory; secretApprovalRequestDAL: TSecretApprovalRequestDALFactory;
secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory; secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory;
secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory; secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findById" | "findSecretPathByFolderIds">; folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findSecretPathByFolderIds">;
secretDAL: TSecretDALFactory; secretDAL: TSecretDALFactory;
secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret">; secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">; secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
@@ -56,16 +63,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany" | "insertMany">; secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany" | "insertMany">;
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">; secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">; projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
secretService: Pick<
TSecretServiceFactory,
| "fnSecretBulkInsert"
| "fnSecretBulkUpdate"
| "fnSecretBlindIndexCheck"
| "fnSecretBulkDelete"
| "fnSecretBlindIndexCheckV2"
>;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets">;
}; };
export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>; export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>;
@@ -82,7 +80,6 @@ export const secretApprovalRequestServiceFactory = ({
projectDAL, projectDAL,
permissionService, permissionService,
snapshotService, snapshotService,
secretService,
secretVersionDAL, secretVersionDAL,
secretQueueService, secretQueueService,
projectBotService projectBotService
@@ -302,11 +299,12 @@ export const secretApprovalRequestServiceFactory = ({
const secretApprovalSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); const secretApprovalSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" }); if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" });
const conflicts: Array<{ secretId: string; op: CommitType }> = []; const conflicts: Array<{ secretId: string; op: SecretOperations }> = [];
let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Create); let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Create);
if (secretCreationCommits.length) { if (secretCreationCommits.length) {
const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({ const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({
folderId, folderId,
secretDAL,
inputSecrets: secretCreationCommits.map(({ secretBlindIndex }) => { inputSecrets: secretCreationCommits.map(({ secretBlindIndex }) => {
if (!secretBlindIndex) { if (!secretBlindIndex) {
throw new BadRequestError({ throw new BadRequestError({
@@ -319,17 +317,19 @@ export const secretApprovalRequestServiceFactory = ({
secretCreationCommits secretCreationCommits
.filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""]) .filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""])
.forEach((el) => { .forEach((el) => {
conflicts.push({ op: CommitType.Create, secretId: el.id }); conflicts.push({ op: SecretOperations.Create, secretId: el.id });
}); });
secretCreationCommits = secretCreationCommits.filter( secretCreationCommits = secretCreationCommits.filter(
({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""] ({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""]
); );
} }
let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Update); let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Update);
if (secretUpdationCommits.length) { if (secretUpdationCommits.length) {
const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({ const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({
folderId, folderId,
secretDAL,
userId: "",
inputSecrets: secretUpdationCommits inputSecrets: secretUpdationCommits
.filter(({ secretBlindIndex, secret }) => secret && secret.secretBlindIndex !== secretBlindIndex) .filter(({ secretBlindIndex, secret }) => secret && secret.secretBlindIndex !== secretBlindIndex)
.map(({ secretBlindIndex }) => { .map(({ secretBlindIndex }) => {
@@ -347,7 +347,7 @@ export const secretApprovalRequestServiceFactory = ({
(secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId (secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId
) )
.forEach((el) => { .forEach((el) => {
conflicts.push({ op: CommitType.Update, secretId: el.id }); conflicts.push({ op: SecretOperations.Update, secretId: el.id });
}); });
secretUpdationCommits = secretUpdationCommits.filter( secretUpdationCommits = secretUpdationCommits.filter(
@@ -356,11 +356,11 @@ export const secretApprovalRequestServiceFactory = ({
); );
} }
const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Delete); const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Delete);
const botKey = await projectBotService.getBotKey(projectId).catch(() => null); const botKey = await projectBotService.getBotKey(projectId).catch(() => null);
const mergeStatus = await secretApprovalRequestDAL.transaction(async (tx) => { const mergeStatus = await secretApprovalRequestDAL.transaction(async (tx) => {
const newSecrets = secretCreationCommits.length const newSecrets = secretCreationCommits.length
? await secretService.fnSecretBulkInsert({ ? await fnSecretBulkInsert({
tx, tx,
folderId, folderId,
inputSecrets: secretCreationCommits.map((el) => ({ inputSecrets: secretCreationCommits.map((el) => ({
@@ -403,7 +403,7 @@ export const secretApprovalRequestServiceFactory = ({
}) })
: []; : [];
const updatedSecrets = secretUpdationCommits.length const updatedSecrets = secretUpdationCommits.length
? await secretService.fnSecretBulkUpdate({ ? await fnSecretBulkUpdate({
folderId, folderId,
projectId, projectId,
tx, tx,
@@ -449,11 +449,13 @@ export const secretApprovalRequestServiceFactory = ({
}) })
: []; : [];
const deletedSecret = secretDeletionCommits.length const deletedSecret = secretDeletionCommits.length
? await secretService.fnSecretBulkDelete({ ? await fnSecretBulkDelete({
projectId, projectId,
folderId, folderId,
tx, tx,
actorId: "", actorId: "",
secretDAL,
secretQueueService,
inputSecrets: secretDeletionCommits.map(({ secretBlindIndex }) => { inputSecrets: secretDeletionCommits.map(({ secretBlindIndex }) => {
if (!secretBlindIndex) { if (!secretBlindIndex) {
throw new BadRequestError({ throw new BadRequestError({
@@ -480,12 +482,14 @@ export const secretApprovalRequestServiceFactory = ({
}; };
}); });
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
const folder = await folderDAL.findById(folderId); const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]);
// TODO(akhilmhdh-pg): change query to do secret path from folder if (!folder) throw new BadRequestError({ message: "Folder not found" });
await secretQueueService.syncSecrets({ await secretQueueService.syncSecrets({
projectId, projectId,
secretPath: "/", secretPath: folder.path,
environment: folder?.environment.envSlug as string environmentSlug: folder.environmentSlug,
actorId,
actor
}); });
return mergeStatus; return mergeStatus;
}; };
@@ -533,9 +537,9 @@ export const secretApprovalRequestServiceFactory = ({
const commits: Omit<TSecretApprovalRequestsSecretsInsert, "requestId">[] = []; const commits: Omit<TSecretApprovalRequestsSecretsInsert, "requestId">[] = [];
const commitTagIds: Record<string, string[]> = {}; const commitTagIds: Record<string, string[]> = {};
// for created secret approval change // for created secret approval change
const createdSecrets = data[CommitType.Create]; const createdSecrets = data[SecretOperations.Create];
if (createdSecrets && createdSecrets?.length) { if (createdSecrets && createdSecrets?.length) {
const { keyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({ const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: createdSecrets, inputSecrets: createdSecrets,
folderId, folderId,
isNew: true, isNew: true,
@@ -546,7 +550,7 @@ export const secretApprovalRequestServiceFactory = ({
commits.push( commits.push(
...createdSecrets.map(({ secretName, ...el }) => ({ ...createdSecrets.map(({ secretName, ...el }) => ({
...el, ...el,
op: CommitType.Create as const, op: SecretOperations.Create as const,
version: 1, version: 1,
secretBlindIndex: keyName2BlindIndex[secretName], secretBlindIndex: keyName2BlindIndex[secretName],
algorithm: SecretEncryptionAlgo.AES_256_GCM, algorithm: SecretEncryptionAlgo.AES_256_GCM,
@@ -558,12 +562,12 @@ export const secretApprovalRequestServiceFactory = ({
}); });
} }
// not secret approval for update operations // not secret approval for update operations
const updatedSecrets = data[CommitType.Update]; const updatedSecrets = data[SecretOperations.Update];
if (updatedSecrets && updatedSecrets?.length) { if (updatedSecrets && updatedSecrets?.length) {
// get all blind index // get all blind index
// Find all those secrets // Find all those secrets
// if not throw not found // if not throw not found
const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await secretService.fnSecretBlindIndexCheck({ const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await fnSecretBlindIndexCheck({
inputSecrets: updatedSecrets, inputSecrets: updatedSecrets,
folderId, folderId,
isNew: false, isNew: false,
@@ -574,8 +578,8 @@ export const secretApprovalRequestServiceFactory = ({
// now find any secret that needs to update its name // now find any secret that needs to update its name
// same process as above // same process as above
const nameUpdatedSecrets = updatedSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); const nameUpdatedSecrets = updatedSecrets.filter(({ newSecretName }) => Boolean(newSecretName));
const { keyName2BlindIndex: newKeyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({ const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets: nameUpdatedSecrets, inputSecrets: nameUpdatedSecrets.map(({ newSecretName }) => ({ secretName: newSecretName as string })),
folderId, folderId,
isNew: true, isNew: true,
blindIndexCfg, blindIndexCfg,
@@ -592,14 +596,14 @@ export const secretApprovalRequestServiceFactory = ({
const secretId = secsGroupedByBlindIndex[keyName2BlindIndex[secretName]][0].id; const secretId = secsGroupedByBlindIndex[keyName2BlindIndex[secretName]][0].id;
const secretBlindIndex = const secretBlindIndex =
newSecretName && newKeyName2BlindIndex[newSecretName] newSecretName && newKeyName2BlindIndex[newSecretName]
? newKeyName2BlindIndex?.[secretName] ? newKeyName2BlindIndex?.[newSecretName]
: keyName2BlindIndex[secretName]; : keyName2BlindIndex[secretName];
// add tags // add tags
if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds; if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds;
return { return {
...latestSecretVersions[secretId], ...latestSecretVersions[secretId],
...el, ...el,
op: CommitType.Update as const, op: SecretOperations.Update as const,
secret: secretId, secret: secretId,
secretVersion: latestSecretVersions[secretId].id, secretVersion: latestSecretVersions[secretId].id,
secretBlindIndex, secretBlindIndex,
@@ -609,12 +613,12 @@ export const secretApprovalRequestServiceFactory = ({
); );
} }
// deleted secrets // deleted secrets
const deletedSecrets = data[CommitType.Delete]; const deletedSecrets = data[SecretOperations.Delete];
if (deletedSecrets && deletedSecrets.length) { if (deletedSecrets && deletedSecrets.length) {
// get all blind index // get all blind index
// Find all those secrets // Find all those secrets
// if not throw not found // if not throw not found
const { keyName2BlindIndex, secrets } = await secretService.fnSecretBlindIndexCheck({ const { keyName2BlindIndex, secrets } = await fnSecretBlindIndexCheck({
inputSecrets: deletedSecrets, inputSecrets: deletedSecrets,
folderId, folderId,
isNew: false, isNew: false,
@@ -635,7 +639,7 @@ export const secretApprovalRequestServiceFactory = ({
if (!latestSecretVersions[secretId].secretBlindIndex) if (!latestSecretVersions[secretId].secretBlindIndex)
throw new BadRequestError({ message: "Failed to find secret blind index" }); throw new BadRequestError({ message: "Failed to find secret blind index" });
return { return {
op: CommitType.Delete as const, op: SecretOperations.Delete as const,
...latestSecretVersions[secretId], ...latestSecretVersions[secretId],
secretBlindIndex: latestSecretVersions[secretId].secretBlindIndex as string, secretBlindIndex: latestSecretVersions[secretId].secretBlindIndex as string,
secret: secretId, secret: secretId,
@@ -1,11 +1,6 @@
import { TImmutableDBKeys, TSecretApprovalPolicies, TSecretApprovalRequestsSecrets } from "@app/db/schemas"; import { TImmutableDBKeys, TSecretApprovalPolicies, TSecretApprovalRequestsSecrets } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { SecretOperations } from "@app/services/secret/secret-types";
export enum CommitType {
Create = "create",
Update = "update",
Delete = "delete"
}
export enum RequestState { export enum RequestState {
Open = "open", Open = "open",
@@ -18,14 +13,14 @@ export enum ApprovalStatus {
REJECTED = "rejected" REJECTED = "rejected"
} }
type TApprovalCreateSecret = Omit< export type TApprovalCreateSecret = Omit<
TSecretApprovalRequestsSecrets, TSecretApprovalRequestsSecrets,
TImmutableDBKeys | "version" | "algorithm" | "keyEncoding" | "requestId" | "op" | "secretVersion" | "secretBlindIndex" TImmutableDBKeys | "version" | "algorithm" | "keyEncoding" | "requestId" | "op" | "secretVersion" | "secretBlindIndex"
> & { > & {
secretName: string; secretName: string;
tagIds?: string[]; tagIds?: string[];
}; };
type TApprovalUpdateSecret = Partial<TApprovalCreateSecret> & { export type TApprovalUpdateSecret = Partial<TApprovalCreateSecret> & {
secretName: string; secretName: string;
newSecretName?: string; newSecretName?: string;
tagIds?: string[]; tagIds?: string[];
@@ -36,9 +31,9 @@ export type TGenerateSecretApprovalRequestDTO = {
secretPath: string; secretPath: string;
policy: TSecretApprovalPolicies; policy: TSecretApprovalPolicies;
data: { data: {
[CommitType.Create]?: TApprovalCreateSecret[]; [SecretOperations.Create]?: TApprovalCreateSecret[];
[CommitType.Update]?: TApprovalUpdateSecret[]; [SecretOperations.Update]?: TApprovalUpdateSecret[];
[CommitType.Delete]?: { secretName: string }[]; [SecretOperations.Delete]?: { secretName: string }[];
}; };
} & TProjectPermission; } & TProjectPermission;
@@ -0,0 +1 @@
export const MAX_REPLICATION_DEPTH = 5;
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSecretReplicationDALFactory = ReturnType<typeof secretReplicationDALFactory>;
export const secretReplicationDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.SecretVersion);
return orm;
};
@@ -0,0 +1,485 @@
import { SecretType, TSecrets } from "@app/db/schemas";
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { groupBy, unique } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { QueueName, TQueueServiceFactory } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
import { fnSecretBulkInsert, fnSecretBulkUpdate } from "@app/services/secret/secret-fns";
import { TSecretQueueFactory, uniqueSecretQueueKey } from "@app/services/secret/secret-queue";
import { SecretOperations } from "@app/services/secret/secret-types";
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { ReservedFolders } from "@app/services/secret-folder/secret-folder-types";
import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
import { fnSecretsFromImports } from "@app/services/secret-import/secret-import-fns";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { MAX_REPLICATION_DEPTH } from "./secret-replication-constants";
type TSecretReplicationServiceFactoryDep = {
secretDAL: Pick<
TSecretDALFactory,
"find" | "findByBlindIndexes" | "insertMany" | "bulkUpdate" | "delete" | "upsertSecretReferences" | "transaction"
>;
secretVersionDAL: Pick<TSecretVersionDALFactory, "find" | "insertMany" | "update" | "findLatestVersionMany">;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "updateById" | "findByFolderIds">;
folderDAL: Pick<
TSecretFolderDALFactory,
"findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath"
>;
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "find" | "insertMany">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "replicateSecrets">;
queueService: Pick<TQueueServiceFactory, "start" | "listen" | "queue" | "stopJobById">;
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret" | "find">;
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findOne">;
secretApprovalRequestSecretDAL: Pick<
TSecretApprovalRequestSecretDALFactory,
"insertMany" | "insertApprovalSecretTags"
>;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
};
export type TSecretReplicationServiceFactory = ReturnType<typeof secretReplicationServiceFactory>;
const SECRET_IMPORT_SUCCESS_LOCK = 10;
const keystoreReplicationSuccessKey = (jobId: string, secretImportId: string) => `${jobId}-${secretImportId}`;
const getReplicationKeyLockPrefix = (projectId: string, environmentSlug: string, secretPath: string) =>
`REPLICATION_SECRET_${projectId}-${environmentSlug}-${secretPath}`;
export const getReplicationFolderName = (importId: string) => `${ReservedFolders.SecretReplication}${importId}`;
const getDecryptedKeyValue = (key: string, secret: TSecrets) => {
const secretKey = decryptSymmetric128BitHexKeyUTF8({
ciphertext: secret.secretKeyCiphertext,
iv: secret.secretKeyIV,
tag: secret.secretKeyTag,
key
});
const secretValue = decryptSymmetric128BitHexKeyUTF8({
ciphertext: secret.secretValueCiphertext,
iv: secret.secretValueIV,
tag: secret.secretValueTag,
key
});
return { key: secretKey, value: secretValue };
};
export const secretReplicationServiceFactory = ({
secretDAL,
queueService,
secretVersionDAL,
secretImportDAL,
keyStore,
secretVersionTagDAL,
secretTagDAL,
folderDAL,
secretApprovalPolicyService,
secretApprovalRequestSecretDAL,
secretApprovalRequestDAL,
secretQueueService,
projectMembershipDAL,
projectBotService
}: TSecretReplicationServiceFactoryDep) => {
const getReplicatedSecrets = (
botKey: string,
localSecrets: TSecrets[],
importedSecrets: { secrets: TSecrets[] }[]
) => {
const deDupe = new Set<string>();
const secrets = localSecrets
.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex))
.map((el) => {
const decryptedSecret = getDecryptedKeyValue(botKey, el);
deDupe.add(decryptedSecret.key);
return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value };
});
for (let i = importedSecrets.length - 1; i >= 0; i = -1) {
importedSecrets[i].secrets.forEach((el) => {
const decryptedSecret = getDecryptedKeyValue(botKey, el);
if (deDupe.has(decryptedSecret.key) || !el.secretBlindIndex) {
return;
}
deDupe.add(decryptedSecret.key);
secrets.push({ ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value });
});
}
return secrets;
};
// IMPORTANT NOTE BEFORE READING THE FUNCTION
// SOURCE - Where secrets are copied from
// DESTINATION - Where the replicated imports that points to SOURCE from Destination
queueService.start(QueueName.SecretReplication, async (job) => {
logger.info(job.data, "Replication started");
const {
secretPath,
environmentSlug,
projectId,
actorId,
actor,
pickOnlyImportIds,
_deDupeReplicationQueue: deDupeReplicationQueue,
_deDupeQueue: deDupeQueue,
_depth: depth = 0
} = job.data;
if (depth > MAX_REPLICATION_DEPTH) return;
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath);
if (!folder) return;
// the the replicated imports made to the source. These are the destinations
const destinationSecretImports = await secretImportDAL.find({
importPath: secretPath,
importEnv: folder.envId
});
// CASE: normal mode <- link import <- replicated import
const nonReplicatedDestinationImports = destinationSecretImports.filter(({ isReplication }) => !isReplication);
if (nonReplicatedDestinationImports.length) {
// keep calling sync secret for all the imports made
const importedFolderIds = unique(nonReplicatedDestinationImports, (i) => i.folderId).map(
({ folderId }) => folderId
);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
await Promise.all(
nonReplicatedDestinationImports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue?.[
uniqueSecretQueueKey(
foldersGroupedById[folderId][0]?.environmentSlug as string,
foldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
secretQueueService.replicateSecrets({
projectId,
secretPath: foldersGroupedById[folderId][0]?.path as string,
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
actorId,
actor,
_depth: depth + 1,
_deDupeReplicationQueue: deDupeReplicationQueue,
_deDupeQueue: deDupeQueue
})
)
);
}
let destinationReplicatedSecretImports = destinationSecretImports.filter(({ isReplication }) =>
Boolean(isReplication)
);
destinationReplicatedSecretImports = pickOnlyImportIds
? destinationReplicatedSecretImports.filter(({ id }) => pickOnlyImportIds?.includes(id))
: destinationReplicatedSecretImports;
if (!destinationReplicatedSecretImports.length) return;
const botKey = await projectBotService.getBotKey(projectId);
// these are the secrets to be added in replicated folders
const sourceLocalSecrets = await secretDAL.find({ folderId: folder.id, type: SecretType.Shared });
const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id });
const sourceImportedSecrets = await fnSecretsFromImports({
allowedImports: sourceSecretImports,
secretDAL,
folderDAL,
secretImportDAL
});
// secrets that gets replicated across imports
const sourceSecrets = getReplicatedSecrets(botKey, sourceLocalSecrets, sourceImportedSecrets);
const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string);
const lock = await keyStore.acquireLock(
[getReplicationKeyLockPrefix(projectId, environmentSlug, secretPath)],
5000
);
try {
/* eslint-disable no-await-in-loop */
for (const destinationSecretImport of destinationReplicatedSecretImports) {
try {
const hasJobCompleted = await keyStore.getItem(
keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id),
KeyStorePrefixes.SecretReplication
);
if (hasJobCompleted) {
logger.info(
{ jobId: job.id, importId: destinationSecretImport.id },
"Skipping this job as this has been successfully replicated."
);
// eslint-disable-next-line
continue;
}
const [destinationFolder] = await folderDAL.findSecretPathByFolderIds(projectId, [
destinationSecretImport.folderId
]);
if (!destinationFolder) throw new BadRequestError({ message: "Imported folder not found" });
let destinationReplicationFolder = await folderDAL.findOne({
parentId: destinationFolder.id,
name: getReplicationFolderName(destinationSecretImport.id),
isReserved: true
});
if (!destinationReplicationFolder) {
destinationReplicationFolder = await folderDAL.create({
parentId: destinationFolder.id,
name: getReplicationFolderName(destinationSecretImport.id),
envId: destinationFolder.envId,
isReserved: true
});
}
const destinationReplicationFolderId = destinationReplicationFolder.id;
const destinationLocalSecretsFromDB = await secretDAL.find({
folderId: destinationReplicationFolderId
});
const destinationLocalSecrets = destinationLocalSecretsFromDB.map((el) => {
const decryptedSecret = getDecryptedKeyValue(botKey, el);
return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value };
});
const destinationLocalSecretsGroupedByBlindIndex = groupBy(
destinationLocalSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)),
(i) => i.secretBlindIndex as string
);
const locallyCreatedSecrets = sourceSecrets
.filter(
({ secretBlindIndex }) => !destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]
)
.map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to create
const locallyUpdatedSecrets = sourceSecrets
.filter(
({ secretBlindIndex, secretKey, secretValue }) =>
destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] &&
// if key or value changed
(destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey ||
destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !==
secretValue)
)
.map((el) => ({ ...el, operation: SecretOperations.Update })); // rewrite update ops to create
const locallyDeletedSecrets = destinationLocalSecrets
.filter(({ secretBlindIndex }) => !sourceSecretsGroupByBlindIndex[secretBlindIndex as string]?.[0])
.map((el) => ({ ...el, operation: SecretOperations.Delete }));
const isEmtpy =
locallyCreatedSecrets.length + locallyUpdatedSecrets.length + locallyDeletedSecrets.length === 0;
// eslint-disable-next-line
if (isEmtpy) continue;
const policy = await secretApprovalPolicyService.getSecretApprovalPolicy(
projectId,
destinationFolder.environmentSlug,
destinationFolder.path
);
// this means it should be a approval request rather than direct replication
if (policy && actor === ActorType.USER) {
const membership = await projectMembershipDAL.findOne({ projectId, userId: actorId });
if (!membership) {
logger.error("Project membership not found in %s for user %s", projectId, actorId);
return;
}
const localSecretsLatestVersions = destinationLocalSecrets.map(({ id }) => id);
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(
destinationReplicationFolderId,
localSecretsLatestVersions
);
await secretApprovalRequestDAL.transaction(async (tx) => {
const approvalRequestDoc = await secretApprovalRequestDAL.create(
{
folderId: destinationReplicationFolderId,
slug: alphaNumericNanoId(),
policyId: policy.id,
status: "open",
hasMerged: false,
committerId: membership.id,
isReplicated: true
},
tx
);
const commits = locallyCreatedSecrets
.concat(locallyUpdatedSecrets)
.concat(locallyDeletedSecrets)
.map((doc) => {
const { operation } = doc;
const localSecret = destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0];
return {
op: operation,
keyEncoding: doc.keyEncoding,
algorithm: doc.algorithm,
requestId: approvalRequestDoc.id,
metadata: doc.metadata,
secretKeyIV: doc.secretKeyIV,
secretKeyTag: doc.secretKeyTag,
secretKeyCiphertext: doc.secretKeyCiphertext,
secretValueIV: doc.secretValueIV,
secretValueTag: doc.secretValueTag,
secretValueCiphertext: doc.secretValueCiphertext,
secretBlindIndex: doc.secretBlindIndex,
secretCommentIV: doc.secretCommentIV,
secretCommentTag: doc.secretCommentTag,
secretCommentCiphertext: doc.secretCommentCiphertext,
skipMultilineEncoding: doc.skipMultilineEncoding,
// except create operation other two needs the secret id and version id
...(operation !== SecretOperations.Create
? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id }
: {})
};
});
const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx);
return { ...approvalRequestDoc, commits: approvalCommits };
});
} else {
await secretDAL.transaction(async (tx) => {
if (locallyCreatedSecrets.length) {
await fnSecretBulkInsert({
folderId: destinationReplicationFolderId,
secretVersionDAL,
secretDAL,
tx,
secretTagDAL,
secretVersionTagDAL,
inputSecrets: locallyCreatedSecrets.map((doc) => {
return {
keyEncoding: doc.keyEncoding,
algorithm: doc.algorithm,
type: doc.type,
metadata: doc.metadata,
secretKeyIV: doc.secretKeyIV,
secretKeyTag: doc.secretKeyTag,
secretKeyCiphertext: doc.secretKeyCiphertext,
secretValueIV: doc.secretValueIV,
secretValueTag: doc.secretValueTag,
secretValueCiphertext: doc.secretValueCiphertext,
secretBlindIndex: doc.secretBlindIndex,
secretCommentIV: doc.secretCommentIV,
secretCommentTag: doc.secretCommentTag,
secretCommentCiphertext: doc.secretCommentCiphertext,
skipMultilineEncoding: doc.skipMultilineEncoding
};
})
});
}
if (locallyUpdatedSecrets.length) {
await fnSecretBulkUpdate({
projectId,
folderId: destinationReplicationFolderId,
secretVersionDAL,
secretDAL,
tx,
secretTagDAL,
secretVersionTagDAL,
inputSecrets: locallyUpdatedSecrets.map((doc) => {
return {
filter: {
folderId: destinationReplicationFolderId,
id: destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id
},
data: {
keyEncoding: doc.keyEncoding,
algorithm: doc.algorithm,
type: doc.type,
metadata: doc.metadata,
secretKeyIV: doc.secretKeyIV,
secretKeyTag: doc.secretKeyTag,
secretKeyCiphertext: doc.secretKeyCiphertext,
secretValueIV: doc.secretValueIV,
secretValueTag: doc.secretValueTag,
secretValueCiphertext: doc.secretValueCiphertext,
secretBlindIndex: doc.secretBlindIndex,
secretCommentIV: doc.secretCommentIV,
secretCommentTag: doc.secretCommentTag,
secretCommentCiphertext: doc.secretCommentCiphertext,
skipMultilineEncoding: doc.skipMultilineEncoding
}
};
})
});
}
if (locallyDeletedSecrets.length) {
await secretDAL.delete(
{
$in: {
id: locallyDeletedSecrets.map(({ id }) => id)
},
folderId: destinationReplicationFolderId
},
tx
);
}
});
await secretQueueService.syncSecrets({
projectId,
secretPath: destinationFolder.path,
environmentSlug: destinationFolder.environmentSlug,
actorId,
actor,
_depth: depth + 1,
_deDupeReplicationQueue: deDupeReplicationQueue,
_deDupeQueue: deDupeQueue
});
}
// this is used to avoid multiple times generating secret approval by failed one
await keyStore.setItemWithExpiry(
keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id),
SECRET_IMPORT_SUCCESS_LOCK,
1,
KeyStorePrefixes.SecretReplication
);
await secretImportDAL.updateById(destinationSecretImport.id, {
lastReplicated: new Date(),
replicationStatus: null,
isReplicationSuccess: true
});
} catch (err) {
logger.error(
err,
`Failed to replicate secret with import id=[${destinationSecretImport.id}] env=[${destinationSecretImport.importEnv.slug}] path=[${destinationSecretImport.importPath}]`
);
await secretImportDAL.updateById(destinationSecretImport.id, {
lastReplicated: new Date(),
replicationStatus: (err as Error)?.message.slice(0, 500),
isReplicationSuccess: false
});
}
}
/* eslint-enable no-await-in-loop */
} finally {
await lock.release();
logger.info(job.data, "Replication finished");
}
});
queueService.listen(QueueName.SecretReplication, "failed", (job, err) => {
logger.error(err, "Failed to replicate secret", job?.data);
});
};
@@ -0,0 +1,3 @@
export type TSyncSecretReplicationDTO = {
id: string;
};
@@ -220,7 +220,7 @@ export const secretSnapshotServiceFactory = ({
const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id); const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id);
// this will remove all secrets and folders on child // 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 // 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 deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx);
const deletedTopLevelFolders = groupBy( const deletedTopLevelFolders = groupBy(
deletedFolders.filter(({ parentId }) => parentId === snapshot.folderId), deletedFolders.filter(({ parentId }) => parentId === snapshot.folderId),
(item) => item.id (item) => item.id
+60 -5
View File
@@ -1,20 +1,75 @@
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Redlock, Settings } from "@app/lib/red-lock";
export type TKeyStoreFactory = ReturnType<typeof keyStoreFactory>; export type TKeyStoreFactory = ReturnType<typeof keyStoreFactory>;
// all the key prefixes used must be set here to avoid conflict
export enum KeyStorePrefixes {
SecretReplication = "secret-replication-import-lock"
}
type TWaitTillReady = {
key: string;
waitingCb?: () => void;
keyCheckCb: (val: string | null) => boolean;
waitIteration?: number;
delay?: number;
jitter?: number;
};
export const keyStoreFactory = (redisUrl: string) => { export const keyStoreFactory = (redisUrl: string) => {
const redis = new Redis(redisUrl); const redis = new Redis(redisUrl);
const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 });
const setItem = async (key: string, value: string | number | Buffer) => redis.set(key, value); const setItem = async (key: string, value: string | number | Buffer, prefix?: string) =>
redis.set(prefix ? `${prefix}:${key}` : key, value);
const getItem = async (key: string) => redis.get(key); const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key);
const setItemWithExpiry = async (key: string, exp: number | string, value: string | number | Buffer) => const setItemWithExpiry = async (
redis.setex(key, exp, value); key: string,
exp: number | string,
value: string | number | Buffer,
prefix?: string
) => redis.setex(prefix ? `${prefix}:${key}` : key, exp, value);
const deleteItem = async (key: string) => redis.del(key); const deleteItem = async (key: string) => redis.del(key);
const incrementBy = async (key: string, value: number) => redis.incrby(key, value); const incrementBy = async (key: string, value: number) => redis.incrby(key, value);
return { setItem, getItem, setItemWithExpiry, deleteItem, incrementBy }; const waitTillReady = async ({
key,
waitingCb,
keyCheckCb,
waitIteration = 10,
delay = 1000,
jitter = 200
}: TWaitTillReady) => {
let attempts = 0;
let isReady = keyCheckCb(await getItem(key));
while (!isReady) {
if (attempts > waitIteration) return;
// eslint-disable-next-line
await new Promise((resolve) => {
waitingCb?.();
setTimeout(resolve, Math.max(0, delay + Math.floor((Math.random() * 2 - 1) * jitter)));
});
attempts += 1;
// eslint-disable-next-line
isReady = keyCheckCb(await getItem(key, "wait_till_ready"));
}
};
return {
setItem,
getItem,
setItemWithExpiry,
deleteItem,
incrementBy,
acquireLock(resources: string[], duration: number, settings?: Partial<Settings>) {
return redisLock.acquire(resources, duration, settings);
},
waitTillReady
};
}; };
+49
View File
@@ -0,0 +1,49 @@
import crypto from "crypto";
import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types";
const getIvLength = () => {
return 12;
};
const getTagLength = () => {
return 16;
};
export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => {
const IV_LENGTH = getIvLength();
const TAG_LENGTH = getTagLength();
const encrypt = (text: Buffer, key: Buffer) => {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(type, key, iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
// Get the authentication tag
const tag = cipher.getAuthTag();
// Concatenate IV, encrypted text, and tag into a single buffer
const ciphertextBlob = Buffer.concat([iv, encrypted, tag]);
return ciphertextBlob;
};
const decrypt = (ciphertextBlob: Buffer, key: Buffer) => {
// Extract the IV, encrypted text, and tag from the buffer
const iv = ciphertextBlob.subarray(0, IV_LENGTH);
const tag = ciphertextBlob.subarray(-TAG_LENGTH);
const encrypted = ciphertextBlob.subarray(IV_LENGTH, -TAG_LENGTH);
const decipher = crypto.createDecipheriv(type, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted;
};
return {
encrypt,
decrypt
};
};
+2
View File
@@ -0,0 +1,2 @@
export { symmetricCipherService } from "./cipher";
export { SymmetricEncryption } from "./types";
+9
View File
@@ -0,0 +1,9 @@
export enum SymmetricEncryption {
AES_GCM_256 = "aes-256-gcm",
AES_GCM_128 = "aes-128-gcm"
}
export type TSymmetricEncryptionFns = {
encrypt: (text: Buffer, key: Buffer) => Buffer;
decrypt: (blob: Buffer, key: Buffer) => Buffer;
};
+2
View File
@@ -11,6 +11,8 @@ import { getConfig } from "../config/env";
export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s); export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s);
export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u); export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u);
export const randomSecureBytes = (length = 32) => crypto.randomBytes(length);
export type TDecryptSymmetricInput = { export type TDecryptSymmetricInput = {
ciphertext: string; ciphertext: string;
iv: string; iv: string;
+2 -1
View File
@@ -9,7 +9,8 @@ export {
encryptAsymmetric, encryptAsymmetric,
encryptSymmetric, encryptSymmetric,
encryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8,
generateAsymmetricKeyPair generateAsymmetricKeyPair,
randomSecureBytes
} from "./encryption"; } from "./encryption";
export { export {
decryptIntegrationAuths, decryptIntegrationAuths,
+1 -1
View File
@@ -128,7 +128,7 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
} }
if ($decr) { if ($decr) {
Object.entries($decr).forEach(([incrementField, incrementValue]) => { Object.entries($decr).forEach(([incrementField, incrementValue]) => {
void query.increment(incrementField, incrementValue); void query.decrement(incrementField, incrementValue);
}); });
} }
const [docs] = await query; const [docs] = await query;
+682
View File
@@ -0,0 +1,682 @@
/* eslint-disable */
// Source code credits: https://github.com/mike-marcacci/node-redlock
// Taken to avoid external dependency
import { randomBytes, createHash } from "crypto";
import { EventEmitter } from "events";
// AbortController became available as a global in node version 16. Once version
// 14 reaches its end-of-life, this can be removed.
import { Redis as IORedisClient, Cluster as IORedisCluster } from "ioredis";
type Client = IORedisClient | IORedisCluster;
// Define script constants.
const ACQUIRE_SCRIPT = `
-- Return 0 if an entry already exists.
for i, key in ipairs(KEYS) do
if redis.call("exists", key) == 1 then
return 0
end
end
-- Create an entry for each provided key.
for i, key in ipairs(KEYS) do
redis.call("set", key, ARGV[1], "PX", ARGV[2])
end
-- Return the number of entries added.
return #KEYS
`;
const EXTEND_SCRIPT = `
-- Return 0 if an entry exists with a *different* lock value.
for i, key in ipairs(KEYS) do
if redis.call("get", key) ~= ARGV[1] then
return 0
end
end
-- Update the entry for each provided key.
for i, key in ipairs(KEYS) do
redis.call("set", key, ARGV[1], "PX", ARGV[2])
end
-- Return the number of entries updated.
return #KEYS
`;
const RELEASE_SCRIPT = `
local count = 0
for i, key in ipairs(KEYS) do
-- Only remove entries for *this* lock value.
if redis.call("get", key) == ARGV[1] then
redis.pcall("del", key)
count = count + 1
end
end
-- Return the number of entries removed.
return count
`;
export type ClientExecutionResult =
| {
client: Client;
vote: "for";
value: number;
}
| {
client: Client;
vote: "against";
error: Error;
};
/*
* This object contains a summary of results.
*/
export type ExecutionStats = {
readonly membershipSize: number;
readonly quorumSize: number;
readonly votesFor: Set<Client>;
readonly votesAgainst: Map<Client, Error>;
};
/*
* This object contains a summary of results. Because the result of an attempt
* can sometimes be determined before all requests are finished, each attempt
* contains a Promise that will resolve ExecutionStats once all requests are
* finished. A rejection of these promises should be considered undefined
* behavior and should cause a crash.
*/
export type ExecutionResult = {
attempts: ReadonlyArray<Promise<ExecutionStats>>;
start: number;
};
/**
*
*/
export interface Settings {
readonly driftFactor: number;
readonly retryCount: number;
readonly retryDelay: number;
readonly retryJitter: number;
readonly automaticExtensionThreshold: number;
}
// Define default settings.
const defaultSettings: Readonly<Settings> = {
driftFactor: 0.01,
retryCount: 10,
retryDelay: 200,
retryJitter: 100,
automaticExtensionThreshold: 500
};
// Modifyng this object is forbidden.
Object.freeze(defaultSettings);
/*
* This error indicates a failure due to the existence of another lock for one
* or more of the requested resources.
*/
export class ResourceLockedError extends Error {
constructor(public readonly message: string) {
super();
this.name = "ResourceLockedError";
}
}
/*
* This error indicates a failure of an operation to pass with a quorum.
*/
export class ExecutionError extends Error {
constructor(
public readonly message: string,
public readonly attempts: ReadonlyArray<Promise<ExecutionStats>>
) {
super();
this.name = "ExecutionError";
}
}
/*
* An object of this type is returned when a resource is successfully locked. It
* contains convenience methods `release` and `extend` which perform the
* associated Redlock method on itself.
*/
export class Lock {
constructor(
public readonly redlock: Redlock,
public readonly resources: string[],
public readonly value: string,
public readonly attempts: ReadonlyArray<Promise<ExecutionStats>>,
public expiration: number
) {}
async release(): Promise<ExecutionResult> {
return this.redlock.release(this);
}
async extend(duration: number): Promise<Lock> {
return this.redlock.extend(this, duration);
}
}
export type RedlockAbortSignal = AbortSignal & { error?: Error };
/**
* A redlock object is instantiated with an array of at least one redis client
* and an optional `options` object. Properties of the Redlock object should NOT
* be changed after it is first used, as doing so could have unintended
* consequences for live locks.
*/
export class Redlock extends EventEmitter {
public readonly clients: Set<Client>;
public readonly settings: Settings;
public readonly scripts: {
readonly acquireScript: { value: string; hash: string };
readonly extendScript: { value: string; hash: string };
readonly releaseScript: { value: string; hash: string };
};
public constructor(
clients: Iterable<Client>,
settings: Partial<Settings> = {},
scripts: {
readonly acquireScript?: string | ((script: string) => string);
readonly extendScript?: string | ((script: string) => string);
readonly releaseScript?: string | ((script: string) => string);
} = {}
) {
super();
// Prevent crashes on error events.
this.on("error", () => {
// Because redlock is designed for high availability, it does not care if
// a minority of redis instances/clusters fail at an operation.
//
// However, it can be helpful to monitor and log such cases. Redlock emits
// an "error" event whenever it encounters an error, even if the error is
// ignored in its normal operation.
//
// This function serves to prevent node's default behavior of crashing
// when an "error" event is emitted in the absence of listeners.
});
// Create a new array of client, to ensure no accidental mutation.
this.clients = new Set(clients);
if (this.clients.size === 0) {
throw new Error("Redlock must be instantiated with at least one redis client.");
}
// Customize the settings for this instance.
this.settings = {
driftFactor: typeof settings.driftFactor === "number" ? settings.driftFactor : defaultSettings.driftFactor,
retryCount: typeof settings.retryCount === "number" ? settings.retryCount : defaultSettings.retryCount,
retryDelay: typeof settings.retryDelay === "number" ? settings.retryDelay : defaultSettings.retryDelay,
retryJitter: typeof settings.retryJitter === "number" ? settings.retryJitter : defaultSettings.retryJitter,
automaticExtensionThreshold:
typeof settings.automaticExtensionThreshold === "number"
? settings.automaticExtensionThreshold
: defaultSettings.automaticExtensionThreshold
};
// Use custom scripts and script modifiers.
const acquireScript =
typeof scripts.acquireScript === "function" ? scripts.acquireScript(ACQUIRE_SCRIPT) : ACQUIRE_SCRIPT;
const extendScript =
typeof scripts.extendScript === "function" ? scripts.extendScript(EXTEND_SCRIPT) : EXTEND_SCRIPT;
const releaseScript =
typeof scripts.releaseScript === "function" ? scripts.releaseScript(RELEASE_SCRIPT) : RELEASE_SCRIPT;
this.scripts = {
acquireScript: {
value: acquireScript,
hash: this._hash(acquireScript)
},
extendScript: {
value: extendScript,
hash: this._hash(extendScript)
},
releaseScript: {
value: releaseScript,
hash: this._hash(releaseScript)
}
};
}
/**
* Generate a sha1 hash compatible with redis evalsha.
*/
private _hash(value: string): string {
return createHash("sha1").update(value).digest("hex");
}
/**
* Generate a cryptographically random string.
*/
private _random(): string {
return randomBytes(16).toString("hex");
}
/**
* This method runs `.quit()` on all client connections.
*/
public async quit(): Promise<void> {
const results = [];
for (const client of this.clients) {
results.push(client.quit());
}
await Promise.all(results);
}
/**
* This method acquires a locks on the resources for the duration specified by
* the `duration`.
*/
public async acquire(resources: string[], duration: number, settings?: Partial<Settings>): Promise<Lock> {
if (Math.floor(duration) !== duration) {
throw new Error("Duration must be an integer value in milliseconds.");
}
const value = this._random();
try {
const { attempts, start } = await this._execute(
this.scripts.acquireScript,
resources,
[value, duration],
settings
);
// Add 2 milliseconds to the drift to account for Redis expires precision,
// which is 1 ms, plus the configured allowable drift factor.
const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2;
return new Lock(this, resources, value, attempts, start + duration - drift);
} catch (error) {
// If there was an error acquiring the lock, release any partial lock
// state that may exist on a minority of clients.
await this._execute(this.scripts.releaseScript, resources, [value], {
retryCount: 0
}).catch(() => {
// Any error here will be ignored.
});
throw error;
}
}
/**
* This method unlocks the provided lock from all servers still persisting it.
* It will fail with an error if it is unable to release the lock on a quorum
* of nodes, but will make no attempt to restore the lock in the case of a
* failure to release. It is safe to re-attempt a release or to ignore the
* error, as the lock will automatically expire after its timeout.
*/
public async release(lock: Lock, settings?: Partial<Settings>): Promise<ExecutionResult> {
// Immediately invalidate the lock.
lock.expiration = 0;
// Attempt to release the lock.
return this._execute(this.scripts.releaseScript, lock.resources, [lock.value], settings);
}
/**
* This method extends a valid lock by the provided `duration`.
*/
public async extend(existing: Lock, duration: number, settings?: Partial<Settings>): Promise<Lock> {
if (Math.floor(duration) !== duration) {
throw new Error("Duration must be an integer value in milliseconds.");
}
// The lock has already expired.
if (existing.expiration < Date.now()) {
throw new ExecutionError("Cannot extend an already-expired lock.", []);
}
const { attempts, start } = await this._execute(
this.scripts.extendScript,
existing.resources,
[existing.value, duration],
settings
);
// Invalidate the existing lock.
existing.expiration = 0;
// Add 2 milliseconds to the drift to account for Redis expires precision,
// which is 1 ms, plus the configured allowable drift factor.
const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2;
const replacement = new Lock(this, existing.resources, existing.value, attempts, start + duration - drift);
return replacement;
}
/**
* Execute a script on all clients. The resulting promise is resolved or
* rejected as soon as this quorum is reached; the resolution or rejection
* will contains a `stats` property that is resolved once all votes are in.
*/
private async _execute(
script: { value: string; hash: string },
keys: string[],
args: (string | number)[],
_settings?: Partial<Settings>
): Promise<ExecutionResult> {
const settings = _settings
? {
...this.settings,
..._settings
}
: this.settings;
// For the purpose of easy config serialization, we treat a retryCount of
// -1 a equivalent to Infinity.
const maxAttempts = settings.retryCount === -1 ? Infinity : settings.retryCount + 1;
const attempts: Promise<ExecutionStats>[] = [];
while (true) {
const { vote, stats, start } = await this._attemptOperation(script, keys, args);
attempts.push(stats);
// The operation achieved a quorum in favor.
if (vote === "for") {
return { attempts, start };
}
// Wait before reattempting.
if (attempts.length < maxAttempts) {
await new Promise((resolve) => {
setTimeout(
resolve,
Math.max(0, settings.retryDelay + Math.floor((Math.random() * 2 - 1) * settings.retryJitter)),
undefined
);
});
} else {
throw new ExecutionError("The operation was unable to achieve a quorum during its retry window.", attempts);
}
}
}
private async _attemptOperation(
script: { value: string; hash: string },
keys: string[],
args: (string | number)[]
): Promise<
| { vote: "for"; stats: Promise<ExecutionStats>; start: number }
| { vote: "against"; stats: Promise<ExecutionStats>; start: number }
> {
const start = Date.now();
return await new Promise((resolve) => {
const clientResults = [];
for (const client of this.clients) {
clientResults.push(this._attemptOperationOnClient(client, script, keys, args));
}
const stats: ExecutionStats = {
membershipSize: clientResults.length,
quorumSize: Math.floor(clientResults.length / 2) + 1,
votesFor: new Set<Client>(),
votesAgainst: new Map<Client, Error>()
};
let done: () => void;
const statsPromise = new Promise<typeof stats>((resolve) => {
done = () => resolve(stats);
});
// This is the expected flow for all successful and unsuccessful requests.
const onResultResolve = (clientResult: ClientExecutionResult): void => {
switch (clientResult.vote) {
case "for":
stats.votesFor.add(clientResult.client);
break;
case "against":
stats.votesAgainst.set(clientResult.client, clientResult.error);
break;
}
// A quorum has determined a success.
if (stats.votesFor.size === stats.quorumSize) {
resolve({
vote: "for",
stats: statsPromise,
start
});
}
// A quorum has determined a failure.
if (stats.votesAgainst.size === stats.quorumSize) {
resolve({
vote: "against",
stats: statsPromise,
start
});
}
// All votes are in.
if (stats.votesFor.size + stats.votesAgainst.size === stats.membershipSize) {
done();
}
};
// This is unexpected and should crash to prevent undefined behavior.
const onResultReject = (error: Error): void => {
throw error;
};
for (const result of clientResults) {
result.then(onResultResolve, onResultReject);
}
});
}
private async _attemptOperationOnClient(
client: Client,
script: { value: string; hash: string },
keys: string[],
args: (string | number)[]
): Promise<ClientExecutionResult> {
try {
let result: number;
try {
// Attempt to evaluate the script by its hash.
// @ts-expect-error
const shaResult = (await client.evalsha(script.hash, keys.length, [...keys, ...args])) as unknown;
if (typeof shaResult !== "number") {
throw new Error(`Unexpected result of type ${typeof shaResult} returned from redis.`);
}
result = shaResult;
} catch (error) {
// If the redis server does not already have the script cached,
// reattempt the request with the script's raw text.
if (!(error instanceof Error) || !error.message.startsWith("NOSCRIPT")) {
throw error;
}
// @ts-expect-error
const rawResult = (await client.eval(script.value, keys.length, [...keys, ...args])) as unknown;
if (typeof rawResult !== "number") {
throw new Error(`Unexpected result of type ${typeof rawResult} returned from redis.`);
}
result = rawResult;
}
// One or more of the resources was already locked.
if (result !== keys.length) {
throw new ResourceLockedError(
`The operation was applied to: ${result} of the ${keys.length} requested resources.`
);
}
return {
vote: "for",
client,
value: result
};
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`Unexpected type ${typeof error} thrown with value: ${error}`);
}
// Emit the error on the redlock instance for observability.
this.emit("error", error);
return {
vote: "against",
client,
error
};
}
}
/**
* Wrap and execute a routine in the context of an auto-extending lock,
* returning a promise of the routine's value. In the case that auto-extension
* fails, an AbortSignal will be updated to indicate that abortion of the
* routine is in order, and to pass along the encountered error.
*
* @example
* ```ts
* await redlock.using([senderId, recipientId], 5000, { retryCount: 5 }, async (signal) => {
* const senderBalance = await getBalance(senderId);
* const recipientBalance = await getBalance(recipientId);
*
* if (senderBalance < amountToSend) {
* throw new Error("Insufficient balance.");
* }
*
* // The abort signal will be true if:
* // 1. the above took long enough that the lock needed to be extended
* // 2. redlock was unable to extend the lock
* //
* // In such a case, exclusivity can no longer be guaranteed for further
* // operations, and should be handled as an exceptional case.
* if (signal.aborted) {
* throw signal.error;
* }
*
* await setBalances([
* {id: senderId, balance: senderBalance - amountToSend},
* {id: recipientId, balance: recipientBalance + amountToSend},
* ]);
* });
* ```
*/
public async using<T>(
resources: string[],
duration: number,
settings: Partial<Settings>,
routine?: (signal: RedlockAbortSignal) => Promise<T>
): Promise<T>;
public async using<T>(
resources: string[],
duration: number,
routine: (signal: RedlockAbortSignal) => Promise<T>
): Promise<T>;
public async using<T>(
resources: string[],
duration: number,
settingsOrRoutine: undefined | Partial<Settings> | ((signal: RedlockAbortSignal) => Promise<T>),
optionalRoutine?: (signal: RedlockAbortSignal) => Promise<T>
): Promise<T> {
if (Math.floor(duration) !== duration) {
throw new Error("Duration must be an integer value in milliseconds.");
}
const settings =
settingsOrRoutine && typeof settingsOrRoutine !== "function"
? {
...this.settings,
...settingsOrRoutine
}
: this.settings;
const routine = optionalRoutine ?? settingsOrRoutine;
if (typeof routine !== "function") {
throw new Error("INVARIANT: routine is not a function.");
}
if (settings.automaticExtensionThreshold > duration - 100) {
throw new Error(
"A lock `duration` must be at least 100ms greater than the `automaticExtensionThreshold` setting."
);
}
// The AbortController/AbortSignal pattern allows the routine to be notified
// of a failure to extend the lock, and subsequent expiration. In the event
// of an abort, the error object will be made available at `signal.error`.
const controller = new AbortController();
const signal = controller.signal as RedlockAbortSignal;
function queue(): void {
timeout = setTimeout(
() => (extension = extend()),
lock.expiration - Date.now() - settings.automaticExtensionThreshold
);
}
async function extend(): Promise<void> {
timeout = undefined;
try {
lock = await lock.extend(duration);
queue();
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`Unexpected thrown ${typeof error}: ${error}.`);
}
if (lock.expiration > Date.now()) {
return (extension = extend());
}
signal.error = error instanceof Error ? error : new Error(`${error}`);
controller.abort();
}
}
let timeout: undefined | NodeJS.Timeout;
let extension: undefined | Promise<void>;
let lock = await this.acquire(resources, duration, settings);
queue();
try {
return await routine(signal);
} finally {
// Clean up the timer.
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
// Wait for an in-flight extension to finish.
if (extension) {
await extension.catch(() => {
// An error here doesn't matter at all, because the routine has
// already completed, and a release will be attempted regardless. The
// only reason for waiting here is to prevent possible contention
// between the extension and release.
});
}
await lock.release();
}
}
}
+4
View File
@@ -7,3 +7,7 @@ export const zpStr = <T extends ZodTypeAny>(schema: T, opt: { stripNull: boolean
if (typeof val !== "string") return val; if (typeof val !== "string") return val;
return val.trim() || undefined; return val.trim() || undefined;
}, schema); }, schema);
export const zodBuffer = z.custom<Buffer>((data) => Buffer.isBuffer(data) || data instanceof Uint8Array, {
message: "Expected binary data (Buffer Or Uint8Array)"
});
+17 -4
View File
@@ -7,6 +7,7 @@ import {
TScanFullRepoEventPayload, TScanFullRepoEventPayload,
TScanPushEventPayload TScanPushEventPayload
} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
import { TSyncSecretsDTO } from "@app/services/secret/secret-types";
export enum QueueName { export enum QueueName {
SecretRotation = "secret-rotation", SecretRotation = "secret-rotation",
@@ -22,7 +23,9 @@ export enum QueueName {
SecretPushEventScan = "secret-push-event-scan", SecretPushEventScan = "secret-push-event-scan",
UpgradeProjectToGhost = "upgrade-project-to-ghost", UpgradeProjectToGhost = "upgrade-project-to-ghost",
DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretRevocation = "dynamic-secret-revocation",
CaCrlRotation = "ca-crl-rotation" CaCrlRotation = "ca-crl-rotation",
SecretReplication = "secret-replication",
SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication
} }
export enum QueueJobs { export enum QueueJobs {
@@ -39,7 +42,9 @@ export enum QueueJobs {
UpgradeProjectToGhost = "upgrade-project-to-ghost-job", UpgradeProjectToGhost = "upgrade-project-to-ghost-job",
DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretRevocation = "dynamic-secret-revocation",
DynamicSecretPruning = "dynamic-secret-pruning", DynamicSecretPruning = "dynamic-secret-pruning",
CaCrlRotation = "ca-crl-rotation-job" CaCrlRotation = "ca-crl-rotation-job",
SecretReplication = "secret-replication",
SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication
} }
export type TQueueJobTypes = { export type TQueueJobTypes = {
@@ -123,6 +128,14 @@ export type TQueueJobTypes = {
caId: string; caId: string;
}; };
}; };
[QueueName.SecretReplication]: {
name: QueueJobs.SecretReplication;
payload: TSyncSecretsDTO;
};
[QueueName.SecretSync]: {
name: QueueJobs.SecretSync;
payload: TSyncSecretsDTO;
};
}; };
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>; export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
@@ -139,7 +152,7 @@ export const queueServiceFactory = (redisUrl: string) => {
const start = <T extends QueueName>( const start = <T extends QueueName>(
name: T, name: T,
jobFn: (job: Job<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>) => Promise<void>, jobFn: (job: Job<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>, token?: string) => Promise<void>,
queueSettings: Omit<QueueOptions, "connection"> = {} queueSettings: Omit<QueueOptions, "connection"> = {}
) => { ) => {
if (queueContainer[name]) { if (queueContainer[name]) {
@@ -173,7 +186,7 @@ export const queueServiceFactory = (redisUrl: string) => {
name: T, name: T,
job: TQueueJobTypes[T]["name"], job: TQueueJobTypes[T]["name"],
data: TQueueJobTypes[T]["payload"], data: TQueueJobTypes[T]["payload"],
opts: JobsOptions & { jobId?: string } opts?: JobsOptions & { jobId?: string }
) => { ) => {
const q = queueContainer[name]; const q = queueContainer[name];
+1 -1
View File
@@ -28,7 +28,7 @@ export const readLimit: RateLimitOptions = {
// POST, PATCH, PUT, DELETE endpoints // POST, PATCH, PUT, DELETE endpoints
export const writeLimit: RateLimitOptions = { export const writeLimit: RateLimitOptions = {
timeWindow: 60 * 1000, timeWindow: 60 * 1000,
max: 50, max: 200, // (too low, FA having issues so increasing it - maidul)
keyGenerator: (req) => req.realIp keyGenerator: (req) => req.realIp
}; };
+42 -10
View File
@@ -44,6 +44,7 @@ import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approva
import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-reviewer-dal"; import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-reviewer-dal";
import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
import { secretReplicationServiceFactory } from "@app/ee/services/secret-replication/secret-replication-service";
import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue"; import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue";
import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
@@ -105,6 +106,9 @@ import { integrationDALFactory } from "@app/services/integration/integration-dal
import { integrationServiceFactory } from "@app/services/integration/integration-service"; import { integrationServiceFactory } from "@app/services/integration/integration-service";
import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal"; import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal";
import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
import { kmsDALFactory } from "@app/services/kms/kms-dal";
import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal";
import { kmsServiceFactory } from "@app/services/kms/kms-service";
import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal";
import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal";
import { orgDALFactory } from "@app/services/org/org-dal"; import { orgDALFactory } from "@app/services/org/org-dal";
@@ -249,8 +253,8 @@ export const registerRoutes = async (
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
const sarReviewerDAL = secretApprovalRequestReviewerDALFactory(db); const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db);
const sarSecretDAL = secretApprovalRequestSecretDALFactory(db); const secretApprovalRequestSecretDAL = secretApprovalRequestSecretDALFactory(db);
const secretRotationDAL = secretRotationDALFactory(db); const secretRotationDAL = secretRotationDALFactory(db);
const snapshotDAL = snapshotDALFactory(db); const snapshotDAL = snapshotDALFactory(db);
@@ -269,6 +273,9 @@ export const registerRoutes = async (
const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretDAL = dynamicSecretDALFactory(db);
const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db);
const kmsDAL = kmsDALFactory(db);
const kmsRootConfigDAL = kmsRootConfigDALFactory(db);
const permissionService = permissionServiceFactory({ const permissionService = permissionServiceFactory({
permissionDAL, permissionDAL,
orgRoleDAL, orgRoleDAL,
@@ -277,6 +284,12 @@ export const registerRoutes = async (
projectDAL projectDAL
}); });
const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore });
const kmsService = kmsServiceFactory({
kmsRootConfigDAL,
keyStore,
kmsDAL
});
const trustedIpService = trustedIpServiceFactory({ const trustedIpService = trustedIpServiceFactory({
licenseService, licenseService,
projectDAL, projectDAL,
@@ -297,7 +310,7 @@ export const registerRoutes = async (
permissionService, permissionService,
auditLogStreamDAL auditLogStreamDAL
}); });
const sapService = secretApprovalPolicyServiceFactory({ const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({
projectMembershipDAL, projectMembershipDAL,
projectEnvDAL, projectEnvDAL,
secretApprovalPolicyApproverDAL: sapApproverDAL, secretApprovalPolicyApproverDAL: sapApproverDAL,
@@ -498,7 +511,7 @@ export const registerRoutes = async (
projectBotDAL, projectBotDAL,
projectMembershipDAL, projectMembershipDAL,
secretApprovalRequestDAL, secretApprovalRequestDAL,
secretApprovalSecretDAL: sarSecretDAL, secretApprovalSecretDAL: secretApprovalRequestSecretDAL,
projectUserMembershipRoleDAL projectUserMembershipRoleDAL
}); });
@@ -633,6 +646,7 @@ export const registerRoutes = async (
secretVersionTagDAL secretVersionTagDAL
}); });
const secretImportService = secretImportServiceFactory({ const secretImportService = secretImportServiceFactory({
licenseService,
projectEnvDAL, projectEnvDAL,
folderDAL, folderDAL,
permissionService, permissionService,
@@ -667,19 +681,18 @@ export const registerRoutes = async (
secretSharingDAL secretSharingDAL
}); });
const sarService = secretApprovalRequestServiceFactory({ const secretApprovalRequestService = secretApprovalRequestServiceFactory({
permissionService, permissionService,
projectBotService, projectBotService,
folderDAL, folderDAL,
secretDAL, secretDAL,
secretTagDAL, secretTagDAL,
secretApprovalRequestSecretDAL: sarSecretDAL, secretApprovalRequestSecretDAL,
secretApprovalRequestReviewerDAL: sarReviewerDAL, secretApprovalRequestReviewerDAL,
projectDAL, projectDAL,
secretVersionDAL, secretVersionDAL,
secretBlindIndexDAL, secretBlindIndexDAL,
secretApprovalRequestDAL, secretApprovalRequestDAL,
secretService,
snapshotService, snapshotService,
secretVersionTagDAL, secretVersionTagDAL,
secretQueueService secretQueueService
@@ -708,6 +721,23 @@ export const registerRoutes = async (
accessApprovalPolicyApproverDAL accessApprovalPolicyApproverDAL
}); });
const secretReplicationService = secretReplicationServiceFactory({
secretTagDAL,
secretVersionTagDAL,
secretDAL,
secretVersionDAL,
secretImportDAL,
keyStore,
queueService,
folderDAL,
secretApprovalPolicyService,
secretBlindIndexDAL,
secretApprovalRequestDAL,
secretApprovalRequestSecretDAL,
secretQueueService,
projectMembershipDAL,
projectBotService
});
const secretRotationQueue = secretRotationQueueFactory({ const secretRotationQueue = secretRotationQueueFactory({
telemetryService, telemetryService,
secretRotationDAL, secretRotationDAL,
@@ -851,6 +881,7 @@ export const registerRoutes = async (
await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startTelemetryCheck();
await dailyResourceCleanUp.startCleanUp(); await dailyResourceCleanUp.startCleanUp();
await kmsService.startService();
// inject all services // inject all services
server.decorate<FastifyZodProvider["services"]>("services", { server.decorate<FastifyZodProvider["services"]>("services", {
@@ -872,6 +903,7 @@ export const registerRoutes = async (
projectEnv: projectEnvService, projectEnv: projectEnvService,
projectRole: projectRoleService, projectRole: projectRoleService,
secret: secretService, secret: secretService,
secretReplication: secretReplicationService,
secretTag: secretTagService, secretTag: secretTagService,
folder: folderService, folder: folderService,
secretImport: secretImportService, secretImport: secretImportService,
@@ -888,10 +920,10 @@ export const registerRoutes = async (
identityGcpAuth: identityGcpAuthService, identityGcpAuth: identityGcpAuthService,
identityAwsAuth: identityAwsAuthService, identityAwsAuth: identityAwsAuthService,
identityAzureAuth: identityAzureAuthService, identityAzureAuth: identityAzureAuthService,
secretApprovalPolicy: sapService,
accessApprovalPolicy: accessApprovalPolicyService, accessApprovalPolicy: accessApprovalPolicyService,
accessApprovalRequest: accessApprovalRequestService, accessApprovalRequest: accessApprovalRequestService,
secretApprovalRequest: sarService, secretApprovalPolicy: secretApprovalPolicyService,
secretApprovalRequest: secretApprovalRequestService,
secretRotation: secretRotationService, secretRotation: secretRotationService,
dynamicSecret: dynamicSecretService, dynamicSecret: dynamicSecretService,
dynamicSecretLease: dynamicSecretLeaseService, dynamicSecretLease: dynamicSecretLeaseService,
@@ -29,7 +29,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
import: z.object({ import: z.object({
environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment), environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment),
path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path) path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path)
}) }),
isReplication: z.boolean().default(false)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -210,6 +211,49 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
} }
}); });
server.route({
method: "POST",
url: "/:secretImportId/replication-resync",
config: {
rateLimit: secretsLimit
},
schema: {
description: "Resync secret replication of secret imports",
security: [
{
bearerAuth: []
}
],
params: z.object({
secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId)
}),
body: z.object({
workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId),
environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment),
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path)
}),
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { message } = await server.services.secretImport.resyncSecretImportReplication({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.secretImportId,
...req.body,
projectId: req.body.workspaceId
});
return { message };
}
});
server.route({ server.route({
method: "GET", method: "GET",
url: "/", url: "/",
@@ -232,11 +276,9 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
200: z.object({ 200: z.object({
message: z.string(), message: z.string(),
secretImports: SecretImportsSchema.omit({ importEnv: true }) secretImports: SecretImportsSchema.omit({ importEnv: true })
.merge( .extend({
z.object({ importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) })
})
)
.array() .array()
}) })
} }
@@ -45,7 +45,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
hashedHex: z.string() hashedHex: z.string()
}), }),
response: { response: {
200: SecretSharingSchema.pick({ name: true, encryptedValue: true, iv: true, tag: true, expiresAt: true }) 200: SecretSharingSchema.pick({
encryptedValue: true,
iv: true,
tag: true,
expiresAt: true,
expiresAfterViews: true
})
} }
}, },
handler: async (req) => { handler: async (req) => {
@@ -55,11 +61,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
); );
if (!sharedSecret) return undefined; if (!sharedSecret) return undefined;
return { return {
name: sharedSecret.name,
encryptedValue: sharedSecret.encryptedValue, encryptedValue: sharedSecret.encryptedValue,
iv: sharedSecret.iv, iv: sharedSecret.iv,
tag: sharedSecret.tag, tag: sharedSecret.tag,
expiresAt: sharedSecret.expiresAt expiresAt: sharedSecret.expiresAt,
expiresAfterViews: sharedSecret.expiresAfterViews
}; };
} }
}); });
@@ -72,14 +78,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}, },
schema: { schema: {
body: z.object({ body: z.object({
name: z.string(),
encryptedValue: z.string(), encryptedValue: z.string(),
iv: z.string(), iv: z.string(),
tag: z.string(), tag: z.string(),
hashedHex: z.string(), hashedHex: z.string(),
expiresAt: z.string().refine((date) => new Date(date) > new Date(), { expiresAt: z
message: "Expires at should be a future date" .string()
}) .refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"),
expiresAfterViews: z.number()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -89,19 +95,19 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const { name, encryptedValue, iv, tag, hashedHex, expiresAt } = req.body; const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
orgId: req.permission.orgId, orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
name,
encryptedValue, encryptedValue,
iv, iv,
tag, tag,
hashedHex, hashedHex,
expiresAt: new Date(expiresAt) expiresAt: new Date(expiresAt),
expiresAfterViews
}); });
return { id: sharedSecret.id }; return { id: sharedSecret.id };
} }
@@ -9,7 +9,6 @@ import {
ServiceTokenScopes ServiceTokenScopes
} from "@app/db/schemas"; } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types";
import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
@@ -19,6 +18,7 @@ import { getUserAgentType } from "@app/server/plugins/audit-log";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ActorType, AuthMode } from "@app/services/auth/auth-type";
import { ProjectFilterType } from "@app/services/project/project-types"; import { ProjectFilterType } from "@app/services/project/project-types";
import { SecretOperations } from "@app/services/secret/secret-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
import { secretRawSchema } from "../sanitizedSchemas"; import { secretRawSchema } from "../sanitizedSchemas";
@@ -902,7 +902,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId, projectId,
policy, policy,
data: { data: {
[CommitType.Create]: [ [SecretOperations.Create]: [
{ {
secretName: req.params.secretName, secretName: req.params.secretName,
secretValueCiphertext, secretValueCiphertext,
@@ -1084,7 +1084,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId, projectId,
policy, policy,
data: { data: {
[CommitType.Update]: [ [SecretOperations.Update]: [
{ {
secretName: req.params.secretName, secretName: req.params.secretName,
newSecretName, newSecretName,
@@ -1234,7 +1234,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId, projectId,
policy, policy,
data: { data: {
[CommitType.Delete]: [ [SecretOperations.Delete]: [
{ {
secretName: req.params.secretName secretName: req.params.secretName
} }
@@ -1364,7 +1364,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId, projectId,
policy, policy,
data: { data: {
[CommitType.Create]: inputSecrets [SecretOperations.Create]: inputSecrets
} }
}); });
@@ -1491,7 +1491,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId, projectId,
policy, policy,
data: { data: {
[CommitType.Update]: inputSecrets.filter(({ type }) => type === "shared") [SecretOperations.Update]: inputSecrets.filter(({ type }) => type === "shared")
} }
}); });
@@ -1606,7 +1606,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
projectId, projectId,
policy, policy,
data: { data: {
[CommitType.Delete]: inputSecrets.filter(({ type }) => type === "shared") [SecretOperations.Delete]: inputSecrets.filter(({ type }) => type === "shared")
} }
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
@@ -27,6 +27,7 @@ import { z } from "zod";
import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types";
import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal";
@@ -521,18 +522,42 @@ const syncSecretsAWSParameterStore = async ({
.promise(); .promise();
} }
// case: secret exists in AWS parameter store // case: secret exists in AWS parameter store
} else if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { } else {
// case: secret value doesn't match one in AWS parameter store
// -> update secret // -> update secret
await ssm if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) {
.putParameter({ await ssm
Name: `${integration.path}${key}`, .putParameter({
Type: "SecureString", Name: `${integration.path}${key}`,
Value: secrets[key].value, Type: "SecureString",
Overwrite: true Value: secrets[key].value,
// Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] Overwrite: true
}) })
.promise(); .promise();
}
if (awsParameterStoreSecretsObj[key].Name) {
try {
await ssm
.addTagsToResource({
ResourceType: "Parameter",
ResourceId: awsParameterStoreSecretsObj[key].Name as string,
Tags: metadata.secretAWSTag
? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({
Key: tag.key,
Value: tag.value
}))
: []
})
.promise();
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((err as any).code === "AccessDeniedException") {
logger.error(
`AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)`
);
}
}
}
} }
await new Promise((resolve) => { await new Promise((resolve) => {
+10
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 TKmsDALFactory = ReturnType<typeof kmsDALFactory>;
export const kmsDALFactory = (db: TDbClient) => {
const kmsOrm = ormify(db, TableName.KmsKey);
return kmsOrm;
};
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TKmsRootConfigDALFactory = ReturnType<typeof kmsRootConfigDALFactory>;
export const kmsRootConfigDALFactory = (db: TDbClient) => {
const kmsOrm = ormify(db, TableName.KmsServerRootConfig);
return kmsOrm;
};
+126
View File
@@ -0,0 +1,126 @@
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { randomSecureBytes } from "@app/lib/crypto";
import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TKmsDALFactory } from "./kms-dal";
import { TKmsRootConfigDALFactory } from "./kms-root-config-dal";
import { TDecryptWithKmsDTO, TEncryptWithKmsDTO, TGenerateKMSDTO } from "./kms-types";
type TKmsServiceFactoryDep = {
kmsDAL: TKmsDALFactory;
kmsRootConfigDAL: Pick<TKmsRootConfigDALFactory, "findById" | "create">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "waitTillReady" | "setItemWithExpiry">;
};
export type TKmsServiceFactory = ReturnType<typeof kmsServiceFactory>;
const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000";
const KMS_ROOT_CREATION_WAIT_KEY = "wait_till_ready_kms_root_key";
const KMS_ROOT_CREATION_WAIT_TIME = 10;
// akhilmhdh: Don't edit this value. This is measured for blob concatination in kms
const KMS_VERSION = "v01";
const KMS_VERSION_BLOB_LENGTH = 3;
export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsServiceFactoryDep) => {
let ROOT_ENCRYPTION_KEY = Buffer.alloc(0);
// this is used symmetric encryption
const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => {
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
const kmsKeyMaterial = randomSecureBytes(32);
const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY);
const { encryptedKey, ...doc } = await kmsDAL.create({
version: 1,
encryptedKey: encryptedKeyMaterial,
encryptionAlgorithm: SymmetricEncryption.AES_GCM_256,
isReserved,
orgId: scopeType === "org" ? scopeId : undefined,
projectId: scopeType === "project" ? scopeId : undefined
});
return doc;
};
const encrypt = async ({ kmsId, plainText }: TEncryptWithKmsDTO) => {
const kmsDoc = await kmsDAL.findById(kmsId);
if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" });
// akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY);
const encryptedPlainTextBlob = cipher.encrypt(plainText, kmsKey);
// Buffer#1 encrypted text + Buffer#2 version number
const versionBlob = Buffer.from(KMS_VERSION, "utf8"); // length is 3
const cipherTextBlob = Buffer.concat([encryptedPlainTextBlob, versionBlob]);
return { cipherTextBlob };
};
const decrypt = async ({ cipherTextBlob: versionedCipherTextBlob, kmsId }: TDecryptWithKmsDTO) => {
const kmsDoc = await kmsDAL.findById(kmsId);
if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" });
// akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY);
const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH);
const decryptedBlob = cipher.decrypt(cipherTextBlob, kmsKey);
return decryptedBlob;
};
const startService = async () => {
const appCfg = getConfig();
// This will switch to a seal process and HMS flow in future
const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY;
// if root key its base64 encoded
const isBase64 = Boolean(appCfg.ROOT_ENCRYPTION_KEY);
if (!encryptionKey) throw new Error("Root encryption key not found for KMS service.");
const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8");
const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null);
if (!lock) {
await keyStore.waitTillReady({
key: KMS_ROOT_CREATION_WAIT_KEY,
keyCheckCb: (val) => val === "true",
waitingCb: () => logger.info("KMS. Waiting for leader to finish creation of KMS Root Key")
});
}
// check if KMS root key was already generated and saved in DB
const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID);
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
if (kmsRootConfig) {
if (lock) await lock.release();
logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting.");
const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer);
// set the flag so that other instancen nodes can start
await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");
logger.info("KMS: Loading ROOT Key into Memory.");
ROOT_ENCRYPTION_KEY = decryptedRootKey;
return;
}
logger.info("KMS: Generating ROOT Key");
const newRootKey = randomSecureBytes(32);
const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer);
// @ts-expect-error id is kept as fixed for idempotence and to avoid race condition
await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID });
// set the flag so that other instancen nodes can start
await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");
logger.info("KMS: Saved and loaded ROOT Key into memory");
if (lock) await lock.release();
ROOT_ENCRYPTION_KEY = newRootKey;
};
return {
startService,
generateKmsKey,
encrypt,
decrypt
};
};
+15
View File
@@ -0,0 +1,15 @@
export type TGenerateKMSDTO = {
scopeType: "project" | "org";
scopeId: string;
isReserved?: boolean;
};
export type TEncryptWithKmsDTO = {
kmsId: string;
plainText: Buffer;
};
export type TDecryptWithKmsDTO = {
kmsId: string;
cipherTextBlob: Buffer;
};
@@ -169,6 +169,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str
// this is for root condition // this is for root condition
// if the given folder id is root folder id then intial path is set as / instead of /root // if the given folder id is root folder id then intial path is set as / instead of /root
// if not root folder the path here will be /<folder name> // if not root folder the path here will be /<folder name>
depth: 1,
path: db.raw(`CONCAT('/', (CASE WHEN "parentId" is NULL THEN '' ELSE ${TableName.SecretFolder}.name END))`), path: db.raw(`CONCAT('/', (CASE WHEN "parentId" is NULL THEN '' ELSE ${TableName.SecretFolder}.name END))`),
child: db.raw("NULL::uuid"), child: db.raw("NULL::uuid"),
environmentSlug: `${TableName.Environment}.slug` environmentSlug: `${TableName.Environment}.slug`
@@ -185,6 +186,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str
.select({ .select({
// then we join join this folder name behind previous as we are going from child to parent // then we join join this folder name behind previous as we are going from child to parent
// the root folder check is used to avoid last / and also root name in folders // the root folder check is used to avoid last / and also root name in folders
depth: db.raw("parent.depth + 1"),
path: db.raw( path: db.raw(
`CONCAT( CASE `CONCAT( CASE
WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' WHEN ${TableName.SecretFolder}."parentId" is NULL THEN ''
@@ -199,7 +201,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str
); );
}) })
.select("*") .select("*")
.from<TSecretFolders & { child: string | null; path: string; environmentSlug: string }>("parent"); .from<TSecretFolders & { child: string | null; path: string; environmentSlug: string; depth: number }>("parent");
export type TSecretFolderDALFactory = ReturnType<typeof secretFolderDALFactory>; export type TSecretFolderDALFactory = ReturnType<typeof secretFolderDALFactory>;
// never change this. If u do write a migration for it // never change this. If u do write a migration for it
@@ -260,12 +262,23 @@ export const secretFolderDALFactory = (db: TDbClient) => {
try { try {
const folders = await sqlFindSecretPathByFolderId(tx || db, projectId, folderIds); const folders = await sqlFindSecretPathByFolderId(tx || db, projectId, folderIds);
// travelling all the way from leaf node to root contains real path
const rootFolders = groupBy( const rootFolders = groupBy(
folders.filter(({ parentId }) => parentId === null), folders.filter(({ parentId }) => parentId === null),
(i) => i.child || i.id // root condition then child and parent will null (i) => i.child || i.id // root condition then child and parent will null
); );
const actualFolders = groupBy(
folders.filter(({ depth }) => depth === 1),
(i) => i.id // root condition then child and parent will null
);
return folderIds.map((folderId) => rootFolders[folderId]?.[0]); return folderIds.map((folderId) => {
if (!rootFolders[folderId]?.[0]) return;
const actualId = rootFolders[folderId][0].child || rootFolders[folderId][0].id;
const folder = actualFolders[actualId][0];
return { ...folder, path: rootFolders[folderId]?.[0].path };
});
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find by secret path" }); throw new DatabaseError({ error, name: "Find by secret path" });
} }
@@ -253,7 +253,7 @@ export const secretFolderServiceFactory = ({
const env = await projectEnvDAL.findOne({ projectId, slug: environment }); const env = await projectEnvDAL.findOne({ projectId, slug: environment });
if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" }); if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" });
const folder = await folderDAL const folder = await folderDAL
.findOne({ envId: env.id, id, parentId: parentFolder.id }) .findOne({ envId: env.id, id, parentId: parentFolder.id, isReserved: false })
// now folder api accepts id based change // now folder api accepts id based change
// this is for cli backward compatiability and when cli removes this, we will remove this logic // this is for cli backward compatiability and when cli removes this, we will remove this logic
.catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id }));
@@ -276,7 +276,11 @@ export const secretFolderServiceFactory = ({
} }
const newFolder = await folderDAL.transaction(async (tx) => { const newFolder = await folderDAL.transaction(async (tx) => {
const [doc] = await folderDAL.update({ envId: env.id, id: folder.id, parentId: parentFolder.id }, { name }, tx); const [doc] = await folderDAL.update(
{ envId: env.id, id: folder.id, parentId: parentFolder.id, isReserved: false },
{ name },
tx
);
await folderVersionDAL.create( await folderVersionDAL.create(
{ {
name: doc.name, name: doc.name,
@@ -324,7 +328,12 @@ export const secretFolderServiceFactory = ({
if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" });
const [doc] = await folderDAL.delete( const [doc] = await folderDAL.delete(
{ envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id }, {
envId: env.id,
[uuidValidate(idOrName) ? "id" : "name"]: idOrName,
parentId: parentFolder.id,
isReserved: false
},
tx tx
); );
if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" });
@@ -354,7 +363,7 @@ export const secretFolderServiceFactory = ({
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!parentFolder) return []; if (!parentFolder) return [];
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id }); const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false });
return folders; return folders;
}; };
@@ -1,5 +1,9 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export enum ReservedFolders {
SecretReplication = "__reserve_replication_"
}
export type TCreateFolderDTO = { export type TCreateFolderDTO = {
environment: string; environment: string;
path: string; path: string;
@@ -15,7 +15,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
try { try {
const docs = await (tx || db)(TableName.SecretFolderVersion) const docs = await (tx || db)(TableName.SecretFolderVersion)
.join(TableName.SecretFolder, `${TableName.SecretFolderVersion}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.SecretFolder, `${TableName.SecretFolderVersion}.folderId`, `${TableName.SecretFolder}.id`)
.where({ parentId: folderId }) .where({ parentId: folderId, isReserved: false })
.join<TSecretFolderVersions>( .join<TSecretFolderVersions>(
(tx || db)(TableName.SecretFolderVersion) (tx || db)(TableName.SecretFolderVersion)
.groupBy("envId", "folderId") .groupBy("envId", "folderId")
@@ -20,14 +20,14 @@ export const secretImportDALFactory = (db: TDbClient) => {
return lastPos?.position || 0; return lastPos?.position || 0;
}; };
const updateAllPosition = async (folderId: string, pos: number, targetPos: number, tx?: Knex) => { const updateAllPosition = async (folderId: string, pos: number, targetPos: number, positionInc = 1, tx?: Knex) => {
try { try {
if (targetPos === -1) { if (targetPos === -1) {
// this means delete // this means delete
await (tx || db)(TableName.SecretImport) await (tx || db)(TableName.SecretImport)
.where({ folderId }) .where({ folderId })
.andWhere("position", ">", pos) .andWhere("position", ">", pos)
.decrement("position", 1); .decrement("position", positionInc);
return; return;
} }
@@ -36,13 +36,13 @@ export const secretImportDALFactory = (db: TDbClient) => {
.where({ folderId }) .where({ folderId })
.where("position", "<=", targetPos) .where("position", "<=", targetPos)
.andWhere("position", ">", pos) .andWhere("position", ">", pos)
.decrement("position", 1); .decrement("position", positionInc);
} else { } else {
await (tx || db)(TableName.SecretImport) await (tx || db)(TableName.SecretImport)
.where({ folderId }) .where({ folderId })
.where("position", ">=", targetPos) .where("position", ">=", targetPos)
.andWhere("position", "<", pos) .andWhere("position", "<", pos)
.increment("position", 1); .increment("position", positionInc);
} }
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Update position" }); throw new DatabaseError({ error, name: "Update position" });
@@ -74,6 +74,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
try { try {
const docs = await (tx || db)(TableName.SecretImport) const docs = await (tx || db)(TableName.SecretImport)
.whereIn("folderId", folderIds) .whereIn("folderId", folderIds)
.where("isReplication", false)
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
.select( .select(
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
@@ -79,7 +79,7 @@ export const fnSecretsFromImports = async ({
let secretsFromDeeperImports: TSecretImportSecrets[] = []; let secretsFromDeeperImports: TSecretImportSecrets[] = [];
if (deeperImports.length) { if (deeperImports.length) {
secretsFromDeeperImports = await fnSecretsFromImports({ secretsFromDeeperImports = await fnSecretsFromImports({
allowedImports: deeperImports, allowedImports: deeperImports.filter(({ isReplication }) => !isReplication),
secretImportDAL, secretImportDAL,
folderDAL, folderDAL,
secretDAL, secretDAL,
@@ -1,7 +1,12 @@
import path from "node:path";
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { TableName } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
@@ -16,6 +21,7 @@ import {
TDeleteSecretImportDTO, TDeleteSecretImportDTO,
TGetSecretImportsDTO, TGetSecretImportsDTO,
TGetSecretsFromImportDTO, TGetSecretsFromImportDTO,
TResyncSecretImportReplicationDTO,
TUpdateSecretImportDTO TUpdateSecretImportDTO
} from "./secret-import-types"; } from "./secret-import-types";
@@ -26,7 +32,8 @@ type TSecretImportServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">; projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
projectEnvDAL: TProjectEnvDALFactory; projectEnvDAL: TProjectEnvDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "replicateSecrets">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
}; };
const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" }); const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" });
@@ -40,7 +47,8 @@ export const secretImportServiceFactory = ({
folderDAL, folderDAL,
projectDAL, projectDAL,
secretDAL, secretDAL,
secretQueueService secretQueueService,
licenseService
}: TSecretImportServiceFactoryDep) => { }: TSecretImportServiceFactoryDep) => {
const createImport = async ({ const createImport = async ({
environment, environment,
@@ -50,7 +58,8 @@ export const secretImportServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
projectId, projectId,
path isReplication,
path: secretPath
}: TCreateSecretImportDTO) => { }: TCreateSecretImportDTO) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
@@ -63,7 +72,7 @@ export const secretImportServiceFactory = ({
// check if user has permission to import into destination path // check if user has permission to import into destination path
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
// check if user has permission to import from target path // check if user has permission to import from target path
@@ -74,10 +83,18 @@ export const secretImportServiceFactory = ({
secretPath: data.path secretPath: data.path
}) })
); );
if (isReplication) {
const plan = await licenseService.getPlan(actorOrgId);
if (!plan.secretApproval) {
throw new BadRequestError({
message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication."
});
}
}
await projectDAL.checkProjectUpgradeStatus(projectId); await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" }); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" });
const [importEnv] = await projectEnvDAL.findBySlugs(projectId, [data.environment]); const [importEnv] = await projectEnvDAL.findBySlugs(projectId, [data.environment]);
@@ -88,35 +105,62 @@ export const secretImportServiceFactory = ({
const existingImport = await secretImportDAL.findOne({ const existingImport = await secretImportDAL.findOne({
folderId: sourceFolder.id, folderId: sourceFolder.id,
importEnv: folder.environment.id, importEnv: folder.environment.id,
importPath: path importPath: secretPath
}); });
if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" });
} }
const secImport = await secretImportDAL.transaction(async (tx) => { const secImport = await secretImportDAL.transaction(async (tx) => {
const lastPos = await secretImportDAL.findLastImportPosition(folder.id, tx); const lastPos = await secretImportDAL.findLastImportPosition(folder.id, tx);
return secretImportDAL.create( const doc = await secretImportDAL.create(
{ {
folderId: folder.id, folderId: folder.id,
position: lastPos + 1, position: lastPos + 1,
importEnv: importEnv.id, importEnv: importEnv.id,
importPath: data.path importPath: data.path,
isReplication
}, },
tx tx
); );
if (doc.isReplication) {
await secretImportDAL.create(
{
folderId: folder.id,
position: lastPos + 2,
isReserved: true,
importEnv: folder.environment.id,
importPath: path.join(secretPath, getReplicationFolderName(doc.id))
},
tx
);
}
return doc;
}); });
await secretQueueService.syncSecrets({ if (secImport.isReplication && sourceFolder) {
secretPath: secImport.importPath, await secretQueueService.replicateSecrets({
projectId, secretPath: secImport.importPath,
environment: importEnv.slug projectId,
}); environmentSlug: importEnv.slug,
pickOnlyImportIds: [secImport.id],
actorId,
actor
});
} else {
await secretQueueService.syncSecrets({
secretPath,
projectId,
environmentSlug: environment,
actorId,
actor
});
}
return { ...secImport, importEnv }; return { ...secImport, importEnv };
}; };
const updateImport = async ({ const updateImport = async ({
path, path: secretPath,
environment, environment,
projectId, projectId,
actor, actor,
@@ -135,10 +179,10 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" });
const secImpDoc = await secretImportDAL.findOne({ folderId: folder.id, id }); const secImpDoc = await secretImportDAL.findOne({ folderId: folder.id, id });
@@ -158,7 +202,7 @@ export const secretImportServiceFactory = ({
const existingImport = await secretImportDAL.findOne({ const existingImport = await secretImportDAL.findOne({
folderId: sourceFolder.id, folderId: sourceFolder.id,
importEnv: folder.environment.id, importEnv: folder.environment.id,
importPath: path importPath: secretPath
}); });
if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" });
} }
@@ -167,12 +211,31 @@ export const secretImportServiceFactory = ({
const secImp = await secretImportDAL.findOne({ folderId: folder.id, id }); const secImp = await secretImportDAL.findOne({ folderId: folder.id, id });
if (!secImp) throw ERR_SEC_IMP_NOT_FOUND; if (!secImp) throw ERR_SEC_IMP_NOT_FOUND;
if (data.position) { if (data.position) {
await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, tx); if (secImp.isReplication) {
await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 2, tx);
} else {
await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 1, tx);
}
}
if (secImp.isReplication) {
const replicationFolderPath = path.join(secretPath, getReplicationFolderName(secImp.id));
await secretImportDAL.update(
{
folderId: folder.id,
importEnv: folder.environment.id,
importPath: replicationFolderPath,
isReserved: true
},
{ position: data?.position ? data.position + 1 : undefined },
tx
);
} }
const [doc] = await secretImportDAL.update( const [doc] = await secretImportDAL.update(
{ id, folderId: folder.id }, { id, folderId: folder.id },
{ {
position: data?.position, // when moving replicated import, the position is meant for reserved import
// replicated one should always be behind the reserved import
position: data.position,
importEnv: data?.environment ? importedEnv.id : undefined, importEnv: data?.environment ? importedEnv.id : undefined,
importPath: data?.path importPath: data?.path
}, },
@@ -184,7 +247,7 @@ export const secretImportServiceFactory = ({
}; };
const deleteImport = async ({ const deleteImport = async ({
path, path: secretPath,
environment, environment,
projectId, projectId,
actor, actor,
@@ -202,16 +265,34 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" }); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" });
const secImport = await secretImportDAL.transaction(async (tx) => { const secImport = await secretImportDAL.transaction(async (tx) => {
const [doc] = await secretImportDAL.delete({ folderId: folder.id, id }, tx); const [doc] = await secretImportDAL.delete({ folderId: folder.id, id }, tx);
if (!doc) throw new BadRequestError({ name: "Sec imp del", message: "Secret import doc not found" }); if (!doc) throw new BadRequestError({ name: "Sec imp del", message: "Secret import doc not found" });
await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, tx); if (doc.isReplication) {
const replicationFolderPath = path.join(secretPath, getReplicationFolderName(doc.id));
const replicatedFolder = await folderDAL.findBySecretPath(projectId, environment, replicationFolderPath, tx);
if (replicatedFolder) {
await secretImportDAL.delete(
{
folderId: folder.id,
importEnv: folder.environment.id,
importPath: replicationFolderPath,
isReserved: true
},
tx
);
await folderDAL.deleteById(replicatedFolder.id, tx);
}
await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 2, tx);
} else {
await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 1, tx);
}
const importEnv = await projectEnvDAL.findById(doc.importEnv); const importEnv = await projectEnvDAL.findById(doc.importEnv);
if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" });
@@ -219,16 +300,91 @@ export const secretImportServiceFactory = ({
}); });
await secretQueueService.syncSecrets({ await secretQueueService.syncSecrets({
secretPath: path, secretPath,
projectId, projectId,
environment environmentSlug: environment,
actor,
actorId
}); });
return secImport; return secImport;
}; };
const resyncSecretImportReplication = async ({
environment,
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
path: secretPath,
id: secretImportDocId
}: TResyncSecretImportReplicationDTO) => {
const { permission, membership } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// check if user has permission to import into destination path
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const plan = await licenseService.getPlan(actorOrgId);
if (!plan.secretApproval) {
throw new BadRequestError({
message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication."
});
}
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" });
const [secretImportDoc] = await secretImportDAL.find({
folderId: folder.id,
[`${TableName.SecretImport}.id` as "id"]: secretImportDocId
});
if (!secretImportDoc) throw new BadRequestError({ message: "Failed to find secret import" });
if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" });
// check if user has permission to import from target path
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment: secretImportDoc.importEnv.slug,
secretPath: secretImportDoc.importPath
})
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const sourceFolder = await folderDAL.findBySecretPath(
projectId,
secretImportDoc.importEnv.slug,
secretImportDoc.importPath
);
if (membership && sourceFolder) {
await secretQueueService.replicateSecrets({
secretPath: secretImportDoc.importPath,
projectId,
environmentSlug: secretImportDoc.importEnv.slug,
pickOnlyImportIds: [secretImportDoc.id],
actorId,
actor
});
}
return { message: "replication started" };
};
const getImports = async ({ const getImports = async ({
path, path: secretPath,
environment, environment,
projectId, projectId,
actor, actor,
@@ -245,10 +401,10 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" }); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
const secImports = await secretImportDAL.find({ folderId: folder.id }); const secImports = await secretImportDAL.find({ folderId: folder.id });
@@ -256,7 +412,7 @@ export const secretImportServiceFactory = ({
}; };
const getSecretsFromImports = async ({ const getSecretsFromImports = async ({
path, path: secretPath,
environment, environment,
projectId, projectId,
actor, actor,
@@ -273,13 +429,13 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, path); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return []; if (!folder) return [];
// this will already order by position // this will already order by position
// so anything based on this order will also be in right position // so anything based on this order will also be in right position
const secretImports = await secretImportDAL.find({ folderId: folder.id }); const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false });
const allowedImports = secretImports.filter(({ importEnv, importPath }) => const allowedImports = secretImports.filter(({ importEnv, importPath }) =>
permission.can( permission.can(
@@ -299,6 +455,7 @@ export const secretImportServiceFactory = ({
deleteImport, deleteImport,
getImports, getImports,
getSecretsFromImports, getSecretsFromImports,
resyncSecretImportReplication,
fnSecretsFromImports fnSecretsFromImports
}; };
}; };
@@ -7,6 +7,7 @@ export type TCreateSecretImportDTO = {
environment: string; environment: string;
path: string; path: string;
}; };
isReplication?: boolean;
} & TProjectPermission; } & TProjectPermission;
export type TUpdateSecretImportDTO = { export type TUpdateSecretImportDTO = {
@@ -16,6 +17,12 @@ export type TUpdateSecretImportDTO = {
data: Partial<{ environment: string; path: string; position: number }>; data: Partial<{ environment: string; path: string; position: number }>;
} & TProjectPermission; } & TProjectPermission;
export type TResyncSecretImportReplicationDTO = {
environment: string;
path: string;
id: string;
} & TProjectPermission;
export type TDeleteSecretImportDTO = { export type TDeleteSecretImportDTO = {
environment: string; environment: string;
path: string; path: string;
@@ -16,17 +16,28 @@ export const secretSharingServiceFactory = ({
secretSharingDAL secretSharingDAL
}: TSecretSharingServiceFactoryDep) => { }: TSecretSharingServiceFactoryDep) => {
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } = const {
createSharedSecretInput; actor,
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); actorId,
if (!permission) throw new UnauthorizedError({ name: "User not in org" }); orgId,
const newSharedSecret = await secretSharingDAL.create({ actorAuthMethod,
name, actorOrgId,
encryptedValue, encryptedValue,
iv, iv,
tag, tag,
hashedHex, hashedHex,
expiresAt, expiresAt,
expiresAfterViews
} = createSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews,
userId: actorId, userId: actorId,
orgId orgId
}); });
@@ -43,9 +54,16 @@ export const secretSharingServiceFactory = ({
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
if (sharedSecret && sharedSecret.expiresAt < new Date()) { if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
return; return;
} }
if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) {
if (sharedSecret.expiresAfterViews === 0) {
await secretSharingDAL.deleteById(sharedSecretId);
return;
}
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
}
return sharedSecret; return sharedSecret;
}; };
@@ -9,12 +9,12 @@ export type TSharedSecretPermission = {
}; };
export type TCreateSharedSecretDTO = { export type TCreateSharedSecretDTO = {
name: string;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
hashedHex: string; hashedHex: string;
expiresAt: Date; expiresAt: Date;
expiresAfterViews: number;
} & TSharedSecretPermission; } & TSharedSecretPermission;
export type TDeleteSharedSecretDTO = { export type TDeleteSharedSecretDTO = {
+57 -1
View File
@@ -32,6 +32,8 @@ import {
TCreateManySecretsRawFn, TCreateManySecretsRawFn,
TCreateManySecretsRawFnFactory, TCreateManySecretsRawFnFactory,
TFnSecretBlindIndexCheck, TFnSecretBlindIndexCheck,
TFnSecretBlindIndexCheckV2,
TFnSecretBulkDelete,
TFnSecretBulkInsert, TFnSecretBulkInsert,
TFnSecretBulkUpdate, TFnSecretBulkUpdate,
TUpdateManySecretsRawFn, TUpdateManySecretsRawFn,
@@ -149,7 +151,8 @@ export const recursivelyGetSecretPaths = ({
// Fetch all folders in env once with a single query // Fetch all folders in env once with a single query
const folders = await folderDAL.find({ const folders = await folderDAL.find({
envId: env.id envId: env.id,
isReserved: false
}); });
// Build the folder hierarchy map // Build the folder hierarchy map
@@ -396,6 +399,30 @@ export const decryptSecretRaw = (
}; };
}; };
// this is used when secret blind index already exist
// mainly for secret approval
export const fnSecretBlindIndexCheckV2 = async ({
inputSecrets,
folderId,
userId,
secretDAL
}: TFnSecretBlindIndexCheckV2) => {
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(({ secretBlindIndex, type }) => ({
blindIndex: secretBlindIndex,
type: type || SecretType.Shared
})),
userId
);
const secsGroupedByBlindIndex = groupBy(secrets, (i) => i.secretBlindIndex as string);
return { secsGroupedByBlindIndex, secrets };
};
/** /**
* Grabs and processes nested secret references from a string * Grabs and processes nested secret references from a string
* *
@@ -598,6 +625,35 @@ export const fnSecretBulkUpdate = async ({
return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); return newSecrets.map((secret) => ({ ...secret, _id: secret.id }));
}; };
export const fnSecretBulkDelete = async ({
folderId,
inputSecrets,
tx,
actorId,
secretDAL,
secretQueueService
}: TFnSecretBulkDelete) => {
const deletedSecrets = await secretDAL.deleteMany(
inputSecrets.map(({ type, secretBlindIndex }) => ({
blindIndex: secretBlindIndex,
type
})),
folderId,
actorId,
tx
);
await Promise.allSettled(
deletedSecrets
.filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays))
.map(({ id, secretReminderRepeatDays }) =>
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number })
)
);
return deletedSecrets;
};
export const createManySecretsRawFnFactory = ({ export const createManySecretsRawFnFactory = ({
projectDAL, projectDAL,
projectBotDAL, projectBotDAL,
+170 -88
View File
@@ -28,7 +28,12 @@ import { TWebhookDALFactory } from "../webhook/webhook-dal";
import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { fnTriggerWebhook } from "../webhook/webhook-fns";
import { TSecretDALFactory } from "./secret-dal"; import { TSecretDALFactory } from "./secret-dal";
import { interpolateSecrets } from "./secret-fns"; import { interpolateSecrets } from "./secret-fns";
import { TCreateSecretReminderDTO, THandleReminderDTO, TRemoveSecretReminderDTO } from "./secret-types"; import {
TCreateSecretReminderDTO,
THandleReminderDTO,
TRemoveSecretReminderDTO,
TSyncSecretsDTO
} from "./secret-types";
export type TSecretQueueFactory = ReturnType<typeof secretQueueFactory>; export type TSecretQueueFactory = ReturnType<typeof secretQueueFactory>;
type TSecretQueueFactoryDep = { type TSecretQueueFactoryDep = {
@@ -59,8 +64,10 @@ export type TGetSecrets = {
}; };
const MAX_SYNC_SECRET_DEPTH = 5; const MAX_SYNC_SECRET_DEPTH = 5;
const uniqueIntegrationKey = (environment: string, secretPath: string) => `integration-${environment}-${secretPath}`; export const uniqueSecretQueueKey = (environment: string, secretPath: string) =>
`secret-queue-dedupe-${environment}-${secretPath}`;
type TIntegrationSecret = Record<string, { value: string; comment?: string; skipMultilineEncoding?: boolean }>;
export const secretQueueFactory = ({ export const secretQueueFactory = ({
queueService, queueService,
integrationDAL, integrationDAL,
@@ -81,68 +88,6 @@ export const secretQueueFactory = ({
secretTagDAL, secretTagDAL,
secretVersionTagDAL secretVersionTagDAL
}: TSecretQueueFactoryDep) => { }: TSecretQueueFactoryDep) => {
const createManySecretsRawFn = createManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL
});
const updateManySecretsRawFn = updateManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL
});
const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record<string, boolean> }) => {
await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, {
attempts: 3,
delay: 1000,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: true,
removeOnFail: true
});
};
const syncSecrets = async ({
deDupeQueue = {},
...dto
}: TGetSecrets & { depth?: number; deDupeQueue?: Record<string, boolean> }) => {
const deDuplicationKey = uniqueIntegrationKey(dto.environment, dto.secretPath);
if (deDupeQueue?.[deDuplicationKey]) {
return;
}
// eslint-disable-next-line
deDupeQueue[deDuplicationKey] = true;
logger.info(
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environment}] [path=${dto.secretPath}]`
);
await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, dto, {
jobId: `secret-webhook-${dto.environment}-${dto.projectId}-${dto.secretPath}`,
removeOnFail: true,
removeOnComplete: true,
delay: 1000,
attempts: 5,
backoff: {
type: "exponential",
delay: 3000
}
});
await syncIntegrations({ ...dto, deDupeQueue });
};
const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
await queueService.stopRepeatableJob( await queueService.stopRepeatableJob(
@@ -237,8 +182,27 @@ export const secretQueueFactory = ({
} }
} }
}; };
const createManySecretsRawFn = createManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL
});
type Content = Record<string, { value: string; comment?: string; skipMultilineEncoding?: boolean }>; const updateManySecretsRawFn = updateManySecretsRawFnFactory({
projectDAL,
projectBotDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL
});
/** /**
* Return the secrets in a given [folderId] including secrets from * Return the secrets in a given [folderId] including secrets from
@@ -251,7 +215,7 @@ export const secretQueueFactory = ({
key: string; key: string;
depth: number; depth: number;
}) => { }) => {
let content: Content = {}; let content: TIntegrationSecret = {};
if (dto.depth > MAX_SYNC_SECRET_DEPTH) { if (dto.depth > MAX_SYNC_SECRET_DEPTH) {
logger.info( logger.info(
`getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]` `getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]`
@@ -301,7 +265,7 @@ export const secretQueueFactory = ({
await expandSecrets(content); await expandSecrets(content);
// check if current folder has any imports from other folders // check if current folder has any imports from other folders
const secretImport = await secretImportDAL.find({ folderId: dto.folderId }); const secretImport = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false });
// if no imports then return secrets in the current folder // if no imports then return secrets in the current folder
if (!secretImport) return content; if (!secretImport) return content;
@@ -333,8 +297,122 @@ export const secretQueueFactory = ({
return content; return content;
}; };
const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record<string, boolean> }) => {
await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, {
attempts: 3,
delay: 1000,
backoff: {
type: "exponential",
delay: 3000
},
removeOnComplete: true,
removeOnFail: true
});
};
const replicateSecrets = async (dto: Omit<TSyncSecretsDTO, "deDupeQueue">) => {
await queueService.queue(QueueName.SecretReplication, QueueJobs.SecretReplication, dto, {
attempts: 3,
backoff: {
type: "exponential",
delay: 2000
},
removeOnComplete: true,
removeOnFail: true
});
};
const syncSecrets = async <T extends boolean = false>({
// seperate de-dupe queue for integration sync and replication sync
_deDupeQueue: deDupeQueue = {},
_depth: depth = 0,
_deDupeReplicationQueue: deDupeReplicationQueue = {},
...dto
}: TSyncSecretsDTO<T>) => {
logger.info(
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]`
);
const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath);
if (
!dto.excludeReplication
? deDupeReplicationQueue?.[deDuplicationKey]
: deDupeQueue?.[deDuplicationKey] || depth > MAX_SYNC_SECRET_DEPTH
) {
return;
}
// eslint-disable-next-line
deDupeQueue[deDuplicationKey] = true;
// eslint-disable-next-line
deDupeReplicationQueue[deDuplicationKey] = true;
await queueService.queue(
QueueName.SecretSync,
QueueJobs.SecretSync,
{
...dto,
_deDupeQueue: deDupeQueue,
_deDupeReplicationQueue: deDupeReplicationQueue,
_depth: depth
} as TSyncSecretsDTO,
{
removeOnFail: true,
removeOnComplete: true,
delay: 1000,
attempts: 5,
backoff: {
type: "exponential",
delay: 3000
}
}
);
};
queueService.start(QueueName.SecretSync, async (job) => {
const {
_deDupeQueue: deDupeQueue,
_deDupeReplicationQueue: deDupeReplicationQueue,
_depth: depth,
secretPath,
projectId,
environmentSlug: environment,
excludeReplication,
actorId,
actor
} = job.data;
await queueService.queue(
QueueName.SecretWebhook,
QueueJobs.SecWebhook,
{ environment, projectId, secretPath },
{
jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`,
removeOnFail: { count: 5 },
removeOnComplete: true,
delay: 1000,
attempts: 5,
backoff: {
type: "exponential",
delay: 3000
}
}
);
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue });
if (!excludeReplication) {
await replicateSecrets({
_deDupeReplicationQueue: deDupeReplicationQueue,
_depth: depth,
projectId,
secretPath,
actorId,
actor,
excludeReplication,
environmentSlug: environment
});
}
});
queueService.start(QueueName.IntegrationSync, async (job) => { queueService.start(QueueName.IntegrationSync, async (job) => {
const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data;
if (depth > MAX_SYNC_SECRET_DEPTH) return;
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) { if (!folder) {
@@ -348,7 +426,8 @@ export const secretQueueFactory = ({
const linkSourceDto = { const linkSourceDto = {
projectId, projectId,
importEnv: folder.environment.id, importEnv: folder.environment.id,
importPath: secretPath importPath: secretPath,
isReplication: false
}; };
const imports = await secretImportDAL.find(linkSourceDto); const imports = await secretImportDAL.find(linkSourceDto);
@@ -356,30 +435,31 @@ export const secretQueueFactory = ({
// keep calling sync secret for all the imports made // keep calling sync secret for all the imports made
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
const foldersGroupedById = groupBy(importedFolders, (i) => i.child || i.id); const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
logger.info( logger.info(
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
); );
await Promise.all( await Promise.all(
imports imports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0].path)) .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
// filter out already synced ones // filter out already synced ones
.filter( .filter(
({ folderId }) => ({ folderId }) =>
!deDupeQueue[ !deDupeQueue[
uniqueIntegrationKey( uniqueSecretQueueKey(
foldersGroupedById[folderId][0].environmentSlug, foldersGroupedById[folderId][0]?.environmentSlug as string,
foldersGroupedById[folderId][0].path foldersGroupedById[folderId][0]?.path as string
) )
] ]
) )
.map(({ folderId }) => .map(({ folderId }) =>
syncSecrets({ syncSecrets({
depth: depth + 1,
projectId, projectId,
secretPath: foldersGroupedById[folderId][0].path, secretPath: foldersGroupedById[folderId][0]?.path as string,
environment: foldersGroupedById[folderId][0].environmentSlug, environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
deDupeQueue _deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
}) })
) )
); );
@@ -393,30 +473,31 @@ export const secretQueueFactory = ({
if (secretReferences.length) { if (secretReferences.length) {
const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
const referencedFoldersGroupedById = groupBy(referencedFolders, (i) => i.child || i.id); const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
logger.info( logger.info(
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
); );
await Promise.all( await Promise.all(
secretReferences secretReferences
.filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0].path)) .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
// filter out already synced ones // filter out already synced ones
.filter( .filter(
({ folderId }) => ({ folderId }) =>
!deDupeQueue[ !deDupeQueue[
uniqueIntegrationKey( uniqueSecretQueueKey(
referencedFoldersGroupedById[folderId][0].environmentSlug, referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
referencedFoldersGroupedById[folderId][0].path referencedFoldersGroupedById[folderId][0]?.path as string
) )
] ]
) )
.map(({ folderId }) => .map(({ folderId }) =>
syncSecrets({ syncSecrets({
depth: depth + 1,
projectId, projectId,
secretPath: referencedFoldersGroupedById[folderId][0].path, secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environment: referencedFoldersGroupedById[folderId][0].environmentSlug, environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
deDupeQueue _deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
}) })
) )
); );
@@ -546,10 +627,11 @@ export const secretQueueFactory = ({
return { return {
// depth is internal only field thus no need to make it available outside // depth is internal only field thus no need to make it available outside
syncSecrets: (dto: TGetSecrets) => syncSecrets(dto), syncSecrets,
syncIntegrations, syncIntegrations,
addSecretReminder, addSecretReminder,
removeSecretReminder, removeSecretReminder,
handleSecretReminder handleSecretReminder,
replicateSecrets
}; };
}; };
+63 -86
View File
@@ -35,6 +35,7 @@ import { TSecretDALFactory } from "./secret-dal";
import { import {
decryptSecretRaw, decryptSecretRaw,
fnSecretBlindIndexCheck, fnSecretBlindIndexCheck,
fnSecretBulkDelete,
fnSecretBulkInsert, fnSecretBulkInsert,
fnSecretBulkUpdate, fnSecretBulkUpdate,
getAllNestedSecretReferences, getAllNestedSecretReferences,
@@ -53,8 +54,6 @@ import {
TDeleteManySecretRawDTO, TDeleteManySecretRawDTO,
TDeleteSecretDTO, TDeleteSecretDTO,
TDeleteSecretRawDTO, TDeleteSecretRawDTO,
TFnSecretBlindIndexCheckV2,
TFnSecretBulkDelete,
TGetASecretDTO, TGetASecretDTO,
TGetASecretRawDTO, TGetASecretRawDTO,
TGetSecretsDTO, TGetSecretsDTO,
@@ -139,53 +138,6 @@ export const secretServiceFactory = ({
return secretBlindIndex; return secretBlindIndex;
}; };
const fnSecretBulkDelete = async ({ folderId, inputSecrets, tx, actorId }: TFnSecretBulkDelete) => {
const deletedSecrets = await secretDAL.deleteMany(
inputSecrets.map(({ type, secretBlindIndex }) => ({
blindIndex: secretBlindIndex,
type
})),
folderId,
actorId,
tx
);
for (const s of deletedSecrets) {
if (s.secretReminderRepeatDays) {
// eslint-disable-next-line no-await-in-loop
await secretQueueService
.removeSecretReminder({
secretId: s.id,
repeatDays: s.secretReminderRepeatDays
})
.catch((err) => {
logger.error(err, `Failed to delete secret reminder for secret with ID ${s?.id}`);
});
}
}
return deletedSecrets;
};
// this is used when secret blind index already exist
// mainly for secret approval
const fnSecretBlindIndexCheckV2 = async ({ inputSecrets, folderId, userId }: TFnSecretBlindIndexCheckV2) => {
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(({ secretBlindIndex, type }) => ({
blindIndex: secretBlindIndex,
type: type || SecretType.Shared
})),
userId
);
const secsGroupedByBlindIndex = groupBy(secrets, (i) => i.secretBlindIndex as string);
return { secsGroupedByBlindIndex, secrets };
};
const createSecret = async ({ const createSecret = async ({
path, path,
actor, actor,
@@ -283,8 +235,13 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); await secretQueueService.syncSecrets({
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot secretPath: path,
actorId,
actor,
projectId,
environmentSlug: folder.environment.slug
});
return { ...secret[0], environment, workspace: projectId, tags, secretPath: path }; return { ...secret[0], environment, workspace: projectId, tags, secretPath: path };
}; };
@@ -413,8 +370,13 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); await secretQueueService.syncSecrets({
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot actor,
actorId,
secretPath: path,
projectId,
environmentSlug: folder.environment.slug
});
return { ...updatedSecret[0], workspace: projectId, environment, secretPath: path }; return { ...updatedSecret[0], workspace: projectId, environment, secretPath: path };
}; };
@@ -470,6 +432,8 @@ export const secretServiceFactory = ({
projectId, projectId,
folderId, folderId,
actorId, actorId,
secretDAL,
secretQueueService,
inputSecrets: [ inputSecrets: [
{ {
type: inputSecret.type as SecretType, type: inputSecret.type as SecretType,
@@ -481,8 +445,13 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); await secretQueueService.syncSecrets({
actor,
actorId,
secretPath: path,
projectId,
environmentSlug: folder.environment.slug
});
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot // TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path }; return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path };
}; };
@@ -551,7 +520,8 @@ export const secretServiceFactory = ({
if (includeImports) { if (includeImports) {
const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId));
const allowedImports = secretImports.filter(({ importEnv, importPath }) => const allowedImports = secretImports.filter(({ importEnv, importPath, isReplication }) =>
!isReplication &&
// if its service token allow full access over imported one // if its service token allow full access over imported one
actor === ActorType.SERVICE actor === ActorType.SERVICE
? true ? true
@@ -656,7 +626,7 @@ export const secretServiceFactory = ({
// then search for imported secrets // then search for imported secrets
// here we consider the import order also thus starting from bottom // here we consider the import order also thus starting from bottom
if (!secret && includeImports) { if (!secret && includeImports) {
const secretImports = await secretImportDAL.find({ folderId }); const secretImports = await secretImportDAL.find({ folderId, isReplication: false });
const allowedImports = secretImports.filter(({ importEnv, importPath }) => const allowedImports = secretImports.filter(({ importEnv, importPath }) =>
// if its service token allow full access over imported one // if its service token allow full access over imported one
actor === ActorType.SERVICE actor === ActorType.SERVICE
@@ -767,7 +737,13 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); await secretQueueService.syncSecrets({
actor,
actorId,
secretPath: path,
projectId,
environmentSlug: folder.environment.slug
});
return newSecrets; return newSecrets;
}; };
@@ -867,7 +843,13 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); await secretQueueService.syncSecrets({
actor,
actorId,
secretPath: path,
projectId,
environmentSlug: folder.environment.slug
});
return secrets; return secrets;
}; };
@@ -917,6 +899,8 @@ export const secretServiceFactory = ({
const secretsDeleted = await secretDAL.transaction(async (tx) => const secretsDeleted = await secretDAL.transaction(async (tx) =>
fnSecretBulkDelete({ fnSecretBulkDelete({
secretDAL,
secretQueueService,
inputSecrets: inputSecrets.map(({ type, secretName }) => ({ inputSecrets: inputSecrets.map(({ type, secretName }) => ({
secretBlindIndex: keyName2BlindIndex[secretName], secretBlindIndex: keyName2BlindIndex[secretName],
type type
@@ -929,7 +913,13 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folderId); await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); await secretQueueService.syncSecrets({
actor,
actorId,
secretPath: path,
projectId,
environmentSlug: folder.environment.slug
});
return secretsDeleted; return secretsDeleted;
}; };
@@ -1109,9 +1099,6 @@ export const secretServiceFactory = ({
skipMultilineEncoding skipMultilineEncoding
}); });
await snapshotService.performSnapshot(secret.folderId);
await secretQueueService.syncSecrets({ secretPath, projectId, environment });
return decryptSecretRaw(secret, botKey); return decryptSecretRaw(secret, botKey);
}; };
@@ -1150,8 +1137,6 @@ export const secretServiceFactory = ({
}); });
await snapshotService.performSnapshot(secret.folderId); await snapshotService.performSnapshot(secret.folderId);
await secretQueueService.syncSecrets({ secretPath, projectId, environment });
return decryptSecretRaw(secret, botKey); return decryptSecretRaw(secret, botKey);
}; };
@@ -1181,9 +1166,6 @@ export const secretServiceFactory = ({
actorAuthMethod actorAuthMethod
}); });
await snapshotService.performSnapshot(secret.folderId);
await secretQueueService.syncSecrets({ secretPath, projectId, environment });
return decryptSecretRaw(secret, botKey); return decryptSecretRaw(secret, botKey);
}; };
@@ -1232,9 +1214,6 @@ export const secretServiceFactory = ({
}) })
}); });
await snapshotService.performSnapshot(secrets[0].folderId);
await secretQueueService.syncSecrets({ secretPath, projectId, environment });
return secrets.map((secret) => return secrets.map((secret) =>
decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey)
); );
@@ -1286,9 +1265,6 @@ export const secretServiceFactory = ({
}) })
}); });
await snapshotService.performSnapshot(secrets[0].folderId);
await secretQueueService.syncSecrets({ secretPath, projectId, environment });
return secrets.map((secret) => return secrets.map((secret) =>
decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey)
); );
@@ -1322,9 +1298,6 @@ export const secretServiceFactory = ({
secrets: inputSecrets.map(({ secretKey }) => ({ secretName: secretKey, type: SecretType.Shared })) secrets: inputSecrets.map(({ secretKey }) => ({ secretName: secretKey, type: SecretType.Shared }))
}); });
await snapshotService.performSnapshot(secrets[0].folderId);
await secretQueueService.syncSecrets({ secretPath, projectId, environment });
return secrets.map((secret) => return secrets.map((secret) =>
decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey)
); );
@@ -1448,7 +1421,12 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folder.id); await snapshotService.performSnapshot(folder.id);
await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); await secretQueueService.syncSecrets({
secretPath,
projectId: project.id,
environmentSlug: environment,
excludeReplication: true
});
return { return {
...updatedSecret[0], ...updatedSecret[0],
@@ -1550,7 +1528,12 @@ export const secretServiceFactory = ({
); );
await snapshotService.performSnapshot(folder.id); await snapshotService.performSnapshot(folder.id);
await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); await secretQueueService.syncSecrets({
secretPath,
projectId: project.id,
environmentSlug: environment,
excludeReplication: true
});
return { return {
...updatedSecret[0], ...updatedSecret[0],
@@ -1624,12 +1607,6 @@ export const secretServiceFactory = ({
updateManySecretsRaw, updateManySecretsRaw,
deleteManySecretsRaw, deleteManySecretsRaw,
getSecretVersions, getSecretVersions,
backfillSecretReferences, backfillSecretReferences
// external services function
fnSecretBulkDelete,
fnSecretBulkUpdate,
fnSecretBlindIndexCheck,
fnSecretBulkInsert,
fnSecretBlindIndexCheckV2
}; };
}; };
@@ -11,6 +11,8 @@ import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/se
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { ActorType } from "../auth/auth-type";
type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">; type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">;
type TPartialInputSecret = Pick<TSecrets, "type" | "secretReminderNote" | "secretReminderRepeatDays" | "id">; type TPartialInputSecret = Pick<TSecrets, "type" | "secretReminderNote" | "secretReminderRepeatDays" | "id">;
@@ -264,6 +266,10 @@ export type TFnSecretBulkDelete = {
inputSecrets: Array<{ type: SecretType; secretBlindIndex: string }>; inputSecrets: Array<{ type: SecretType; secretBlindIndex: string }>;
actorId: string; actorId: string;
tx?: Knex; tx?: Knex;
secretDAL: Pick<TSecretDALFactory, "deleteMany">;
secretQueueService: {
removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise<void>;
};
}; };
export type TFnSecretBlindIndexCheck = { export type TFnSecretBlindIndexCheck = {
@@ -277,6 +283,7 @@ export type TFnSecretBlindIndexCheck = {
// when blind index is already present // when blind index is already present
export type TFnSecretBlindIndexCheckV2 = { export type TFnSecretBlindIndexCheckV2 = {
secretDAL: Pick<TSecretDALFactory, "findByBlindIndexes">;
folderId: string; folderId: string;
userId?: string; userId?: string;
inputSecrets: Array<{ secretBlindIndex: string; type?: SecretType }>; inputSecrets: Array<{ secretBlindIndex: string; type?: SecretType }>;
@@ -363,3 +370,27 @@ export type TUpdateManySecretsRawFn = {
}[]; }[];
userId?: string; userId?: string;
}; };
export enum SecretOperations {
Create = "create",
Update = "update",
Delete = "delete"
}
export type TSyncSecretsDTO<T extends boolean = false> = {
_deDupeQueue?: Record<string, boolean>;
_deDupeReplicationQueue?: Record<string, boolean>;
_depth?: number;
secretPath: string;
projectId: string;
environmentSlug: string;
// cases for just doing sync integration and webhook
excludeReplication?: T;
} & (T extends true
? object
: {
actor: ActorType;
actorId: string;
// used for import creation to trigger replication
pickOnlyImportIds?: string[];
});
@@ -89,6 +89,7 @@ export const secretVersionDALFactory = (db: TDbClient) => {
const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => {
try { try {
if (!secretIds.length) return {};
const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(TableName.SecretVersion) const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(TableName.SecretVersion)
.where("folderId", folderId) .where("folderId", folderId)
.whereIn(`${TableName.SecretVersion}.secretId`, secretIds) .whereIn(`${TableName.SecretVersion}.secretId`, secretIds)
+9 -10
View File
@@ -1,37 +1,36 @@
--- ---
title: "Secret Sharing" title: "Secret Sharing"
sidebarTitle: "Secret Sharing" sidebarTitle: "Secret Sharing"
description: "Learn how to share time-bound secrets securely with anyone on the internet." description: "Learn how to share time & view-count bound secrets securely with anyone on the internet."
--- ---
Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse.
Infisical offers a secure solution for sharing secrets over the internet in a time-bound manner. Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner.
With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself.
## Share a Secret ## Share a Secret
1. Navigate to the **Projects** page. 1. Navigate to the **Organization** page.
2. Click on the **Secret Sharing** tab from the sidebar. 2. Click on the **Secret Sharing** tab from the sidebar.
![Secret Sharing](../../images/platform/secret-sharing/overview.png) ![Secret Sharing](../../images/platform/secret-sharing/overview.png)
3. Click on the **Share Secret** button.
<Note> <Note>
Infisical does not have access to the shared secrets. This is a part of our zero Infisical does not have access to the shared secrets. This is a part of our
knowledge architecture. zero knowledge architecture.
</Note> </Note>
4. Enter the secret you want to share and set the expiration time. Click on the **Share Secret** button. 3. Click on the **Share Secret** button. Set the secret, its expiration time as well as the number of views allowed. It expires as soon as any of the conditions are met.
![Add Sharing Secret](../../images/platform/secret-sharing/new-secret.png) ![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png)
<Note> <Note>
Secret once set cannot be changed. This is to ensure that the secret is not Secret once set cannot be changed. This is to ensure that the secret is not
tampered with. tampered with.
</Note> </Note>
5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration time. Hence, it is recommended to share the link only with the intended recipient. 5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration condition. Hence, it is recommended to share the link only with the intended recipient.
![Copy URL](../../images/platform/secret-sharing/copy-url.png) ![Copy URL](../../images/platform/secret-sharing/copy-url.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 467 KiB

@@ -28,6 +28,7 @@ Prerequisites:
"Action": [ "Action": [
"ssm:PutParameter", "ssm:PutParameter",
"ssm:DeleteParameter", "ssm:DeleteParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath", "ssm:GetParametersByPath",
"ssm:DeleteParameters", "ssm:DeleteParameters",
"ssm:AddTagsToResource", // if you need to add tags to secrets "ssm:AddTagsToResource", // if you need to add tags to secrets
@@ -33,7 +33,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
``` ```
<Warning> <Warning>
Do you not use the latest docker image tag in production deployments as they can introduce unexpected changes Do not use the latest docker image tag in production deployments as they can introduce unexpected changes
</Warning> </Warning>
</Step> </Step>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

After

Width:  |  Height:  |  Size: 212 KiB

+2 -1
View File
@@ -62,6 +62,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
<SelectPrimitive.Content <SelectPrimitive.Content
className={twMerge( className={twMerge(
"relative top-1 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md", "relative top-1 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md",
position === "popper" && "max-h-72",
dropdownContainerClassName dropdownContainerClassName
)} )}
position={position} position={position}
@@ -113,7 +114,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`, outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`,
isSelected && "bg-primary", isSelected && "bg-primary",
isDisabled && isDisabled &&
"cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600",
className className
)} )}
ref={forwardedRef} ref={forwardedRef}
+5
View File
@@ -0,0 +1,5 @@
export const removeTrailingSlash = (str: string) => {
if (str === "/") return str;
return str.endsWith("/") ? str.slice(0, -1) : str;
};
@@ -220,6 +220,7 @@ export const useGetSecretApprovalRequestCount = ({
}) => }) =>
useQuery({ useQuery({
queryKey: secretApprovalRequestKeys.count({ workspaceId }), queryKey: secretApprovalRequestKeys.count({ workspaceId }),
refetchInterval: 5000,
queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }), queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true) enabled: Boolean(workspaceId) && (options?.enabled ?? true)
}); });
@@ -44,6 +44,7 @@ export type TSecretApprovalSecChange = {
export type TSecretApprovalRequest<J extends unknown = EncryptedSecret> = { export type TSecretApprovalRequest<J extends unknown = EncryptedSecret> = {
id: string; id: string;
isReplicated?: boolean;
slug: string; slug: string;
createdAt: string; createdAt: string;
committerId: string; committerId: string;
@@ -1,3 +1,7 @@
export enum ReservedFolders {
SecretReplication = "__reserve_replication_"
}
export type TSecretFolder = { export type TSecretFolder = {
id: string; id: string;
name: string; name: string;
@@ -1,4 +1,9 @@
export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation"; export {
useCreateSecretImport,
useDeleteSecretImport,
useResyncSecretReplication,
useUpdateSecretImport
} from "./mutation";
export { export {
useGetImportedFoldersByEnv, useGetImportedFoldersByEnv,
useGetImportedSecretsAllEnvs, useGetImportedSecretsAllEnvs,
@@ -3,18 +3,24 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { secretImportKeys } from "./queries"; import { secretImportKeys } from "./queries";
import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; import {
TCreateSecretImportDTO,
TDeleteSecretImportDTO,
TResyncSecretReplicationDTO,
TUpdateSecretImportDTO
} from "./types";
export const useCreateSecretImport = () => { export const useCreateSecretImport = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretImportDTO>({ return useMutation<{}, {}, TCreateSecretImportDTO>({
mutationFn: async ({ import: secretImport, environment, projectId, path }) => { mutationFn: async ({ import: secretImport, environment, isReplication, projectId, path }) => {
const { data } = await apiRequest.post("/api/v1/secret-imports", { const { data } = await apiRequest.post("/api/v1/secret-imports", {
import: secretImport, import: secretImport,
environment, environment,
workspaceId: projectId, workspaceId: projectId,
path path,
isReplication
}); });
return data; return data;
}, },
@@ -53,6 +59,19 @@ export const useUpdateSecretImport = () => {
}); });
}; };
export const useResyncSecretReplication = () => {
return useMutation<{}, {}, TResyncSecretReplicationDTO>({
mutationFn: async ({ environment, projectId, path, id }) => {
const { data } = await apiRequest.post(`/api/v1/secret-imports/${id}/replication-resync`, {
environment,
path,
workspaceId: projectId
});
return data;
}
});
};
export const useDeleteSecretImport = () => { export const useDeleteSecretImport = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -10,6 +10,11 @@ export type TSecretImport = {
position: string; position: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isReserved?: boolean;
isReplication?: boolean;
isReplicationSuccess?: boolean;
replicationStatus?: string;
lastReplicated?: string;
}; };
export type TGetImportedFoldersByEnvDTO = { export type TGetImportedFoldersByEnvDTO = {
@@ -60,6 +65,7 @@ export type TCreateSecretImportDTO = {
environment: string; environment: string;
path: string; path: string;
}; };
isReplication?: boolean;
}; };
export type TUpdateSecretImportDTO = { export type TUpdateSecretImportDTO = {
@@ -74,6 +80,13 @@ export type TUpdateSecretImportDTO = {
}>; }>;
}; };
export type TResyncSecretReplicationDTO = {
id: string;
projectId: string;
environment: string;
path?: string;
};
export type TDeleteSecretImportDTO = { export type TDeleteSecretImportDTO = {
id: string; id: string;
projectId: string; projectId: string;
@@ -8,9 +8,7 @@ export const useGetSharedSecrets = () => {
return useQuery({ return useQuery({
queryKey: ["sharedSecrets"], queryKey: ["sharedSecrets"],
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<TSharedSecret[]>( const { data } = await apiRequest.get<TSharedSecret[]>("/api/v1/secret-sharing/");
"/api/v1/secret-sharing/"
);
return data; return data;
} }
}); });
@@ -23,11 +21,9 @@ export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex:
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
); );
return { return {
name: data.name,
encryptedValue: data.encryptedValue, encryptedValue: data.encryptedValue,
iv: data.iv, iv: data.iv,
tag: data.tag, tag: data.tag,
expiresAt: data.expiresAt
}; };
} }
}); });
+3 -10
View File
@@ -1,31 +1,24 @@
export type TSharedSecret = { export type TSharedSecret = {
id: string; id: string;
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
userId: string; userId: string;
expiresAt: Date; orgId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; } & TCreateSharedSecretRequest;
export type TCreateSharedSecretRequest = { export type TCreateSharedSecretRequest = {
name: string;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
hashedHex: string; hashedHex: string;
expiresAt: Date; expiresAt: Date;
expiresAfterViews: number;
}; };
export type TViewSharedSecretResponse = { export type TViewSharedSecretResponse = {
name: string;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
expiresAt: Date;
}; };
export type TDeleteSharedSecretRequest = { export type TDeleteSharedSecretRequest = {
+9
View File
@@ -0,0 +1,9 @@
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
export const formatReservedPaths = (secretPath: string) => {
const i = secretPath.indexOf(ReservedFolders.SecretReplication);
if (i !== -1) {
return `${secretPath.slice(0, i)} - (replication)`;
}
return secretPath;
};
@@ -43,6 +43,7 @@ import {
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { removeTrailingSlash } from "@app/helpers/string";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
TProjectUserPrivilege, TProjectUserPrivilege,
@@ -104,7 +105,9 @@ export const SpecificPrivilegeSecretForm = ({
? { ? {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment, environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator // secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob
? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob)
: "",
read: privilege.permissions?.some(({ action }) => read: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Read) action.includes(ProjectPermissionActions.Read)
), ),
@@ -183,7 +186,7 @@ export const SpecificPrivilegeSecretForm = ({
]; ];
const conditions: Record<string, any> = { environment: data.environmentSlug }; const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) { if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath }; conditions.secretPath = { $glob: removeTrailingSlash(data.secretPath) };
} }
await updateUserPrivilege.mutateAsync({ await updateUserPrivilege.mutateAsync({
privilegeId: privilege.id, privilegeId: privilege.id,
@@ -212,7 +212,8 @@ export const SecretApprovalRequest = () => {
createdAt, createdAt,
policy, policy,
reviewers, reviewers,
status status,
isReplicated: isReplication
} = secretApproval; } = secretApproval;
const isApprover = policy?.approvers?.indexOf(myMembershipId || "") !== -1; const isApprover = policy?.approvers?.indexOf(myMembershipId || "") !== -1;
const isReviewed = const isReviewed =
@@ -240,8 +241,9 @@ export const SecretApprovalRequest = () => {
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{membersGroupById?.[committerId]?.user?.firstName}{" "} {membersGroupById?.[committerId]?.user?.firstName}{" "}
{membersGroupById?.[committerId]?.user?.lastName} ( {membersGroupById?.[committerId]?.user?.lastName} (
{membersGroupById?.[committerId]?.user?.email}){" "} {membersGroupById?.[committerId]?.user?.email})
{isApprover && !isReviewed && status === "open" && "- Review required"} {isReplication && " via replication"}
{isApprover && !isReviewed && status === "open" && " - Review required"}
</span> </span>
</div> </div>
); );
@@ -20,6 +20,7 @@ import {
useUpdateSecretApprovalReviewStatus useUpdateSecretApprovalReviewStatus
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem"; import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
@@ -185,6 +186,9 @@ export const SecretApprovalRequestChanges = ({
<div className="flex flex-grow flex-col"> <div className="flex flex-grow flex-col">
<div className="mb-1 text-lg"> <div className="mb-1 text-lg">
{generateCommitText(secretApprovalRequestDetails.commits)} {generateCommitText(secretApprovalRequestDetails.commits)}
{secretApprovalRequestDetails.isReplicated && (
<span className="text-sm text-bunker-300"> (replication)</span>
)}
</div> </div>
<div className="flex items-center text-sm text-bunker-300"> <div className="flex items-center text-sm text-bunker-300">
{committer?.user?.firstName} {committer?.user?.firstName}
@@ -197,7 +201,11 @@ export const SecretApprovalRequestChanges = ({
<div className="border-r border-mineshaft-500 pr-1"> <div className="border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" /> <FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
</div> </div>
<div className="pl-2 pb-0.5 text-sm">{secretApprovalRequestDetails.secretPath}</div> <Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
<div className="truncate pl-2 pb-0.5 text-sm" style={{ maxWidth: "10rem" }}>
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
</div>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
@@ -482,6 +482,7 @@ export const ActionBar = ({
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isOpen={popUp.addSecretImport.isOpen} isOpen={popUp.addSecretImport.isOpen}
onClose={() => handlePopUpClose("addSecretImport")} onClose={() => handlePopUpClose("addSecretImport")}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
@@ -4,9 +4,16 @@ import { AxiosError } from "axios";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import {
Button,
FormControl,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context"; import { useSubscription, useWorkspace } from "@app/context";
import { useCreateSecretImport } from "@app/hooks/api"; import { useCreateSecretImport } from "@app/hooks/api";
const typeSchema = z.object({ const typeSchema = z.object({
@@ -16,7 +23,8 @@ const typeSchema = z.object({
.trim() .trim()
.transform((val) => .transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
) ),
isReplication: z.boolean().default(false)
}); });
type TFormSchema = z.infer<typeof typeSchema>; type TFormSchema = z.infer<typeof typeSchema>;
@@ -29,6 +37,7 @@ type Props = {
isOpen?: boolean; isOpen?: boolean;
onClose: () => void; onClose: () => void;
onTogglePopUp: (isOpen: boolean) => void; onTogglePopUp: (isOpen: boolean) => void;
onUpgradePlan: () => void;
}; };
export const CreateSecretImportForm = ({ export const CreateSecretImportForm = ({
@@ -37,7 +46,8 @@ export const CreateSecretImportForm = ({
secretPath = "/", secretPath = "/",
isOpen, isOpen,
onClose, onClose,
onTogglePopUp onTogglePopUp,
onUpgradePlan
}: Props) => { }: Props) => {
const { const {
handleSubmit, handleSubmit,
@@ -49,18 +59,26 @@ export const CreateSecretImportForm = ({
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
const selectedEnvironment = watch("environment"); const selectedEnvironment = watch("environment");
const { subscription } = useSubscription();
const { mutateAsync: createSecretImport } = useCreateSecretImport(); const { mutateAsync: createSecretImport } = useCreateSecretImport();
const handleFormSubmit = async ({ const handleFormSubmit = async ({
environment: importedEnv, environment: importedEnv,
secretPath: importedSecPath secretPath: importedSecPath,
isReplication
}: TFormSchema) => { }: TFormSchema) => {
try { try {
if (isReplication && !subscription?.secretApproval) {
onUpgradePlan();
return;
}
await createSecretImport({ await createSecretImport({
environment, environment,
projectId: workspaceId, projectId: workspaceId,
path: secretPath, path: secretPath,
isReplication,
import: { import: {
environment: importedEnv, environment: importedEnv,
path: importedSecPath path: importedSecPath
@@ -70,7 +88,8 @@ export const CreateSecretImportForm = ({
reset(); reset();
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully linked" text: `Successfully linked. ${isReplication ? "Please refresh the dashboard to view changes" : ""
}`
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -127,7 +146,31 @@ export const CreateSecretImportForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Controller
name="isReplication"
control={control}
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
value
? "Secrets from the source will be automatically sent to the destination. If approval policies exist at the destination, the secrets will be sent as approval requests instead of being applied immediately."
: "Secrets from the source location will be imported to the selected destination immediately, ignoring any approval policies at the destination."
}
>
<Select
value={value ? "true" : "false"}
onValueChange={(val) => onChange(val === "true")}
className="w-full border border-mineshaft-500"
>
<SelectItem value="false">Ignore secret approval polices</SelectItem>
<SelectItem value="true">Respect secret approval polices</SelectItem>
</Select>
</FormControl>
)}
/>
<div className="mt-7 flex items-center"> <div className="mt-7 flex items-center">
<Button <Button
isDisabled={isSubmitting} isDisabled={isSubmitting}
@@ -2,36 +2,61 @@ import { useEffect } from "react";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { import {
faCalendarCheck,
faClose, faClose,
faFileImport, faFileImport,
faFolder, faFolder,
faInfoCircle,
faKey, faKey,
faUpDown faRotate,
faUpDown,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2"; import {
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; EmptyState,
IconButton,
SecretInput,
TableContainer,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { useResyncSecretReplication } from "@app/hooks/api";
import { TSecretImport } from "@app/hooks/api/types";
type Props = { type Props = {
onDelete: () => void; onDelete: () => void;
environment: string; environment: string;
secretPath?: string; secretPath?: string;
importEnvName: string; secretImport?: TSecretImport;
importEnvPath: string; isReplicationExpand?: boolean;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[]; importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
searchTerm: string; searchTerm: string;
id: string; onExpandReplicateSecrets: (id: string) => void;
}; };
// to show the environment and folder icon // to show the environment and folder icon
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => ( export const EnvFolderIcon = ({
env,
secretPath,
// isReplication
}: {
env: string;
secretPath: string;
// isReplication?: boolean;
}) => (
<div className="inline-flex items-center space-x-2"> <div className="inline-flex items-center space-x-2">
<div style={{ minWidth: "96px" }}>{env || "-"}</div> <div style={{ minWidth: "96px" }}>{env || "-"}</div>
{secretPath && ( {secretPath && (
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2"> <div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
{/* {isReplication && <Tag size="xs">Replication Mode</Tag>} */}
<FontAwesomeIcon icon={faFolder} className="text-md text-green-700" /> <FontAwesomeIcon icon={faFolder} className="text-md text-green-700" />
<span>{secretPath}</span> <span>{secretPath}</span>
</div> </div>
@@ -41,19 +66,29 @@ export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: st
export const SecretImportItem = ({ export const SecretImportItem = ({
onDelete, onDelete,
id, isReplicationExpand,
importEnvName,
importEnvPath,
importedSecrets = [], importedSecrets = [],
searchTerm = "", searchTerm = "",
secretPath, secretPath,
environment environment,
secretImport,
onExpandReplicateSecrets: onExpandReplicate
}: Props) => { }: Props) => {
const {
isReserved,
id,
isReplication,
isReplicationSuccess,
replicationStatus,
lastReplicated,
importEnv
} = secretImport as TSecretImport;
const { currentWorkspace } = useWorkspace();
const [isExpanded, setIsExpanded] = useToggle(); const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id id
}); });
const resyncSecretReplication = useResyncSecretReplication();
useEffect(() => { useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) => const filteredSecrets = importedSecrets.filter((secret) =>
secret.key.toUpperCase().includes(searchTerm.toUpperCase()) secret.key.toUpperCase().includes(searchTerm.toUpperCase())
@@ -77,22 +112,127 @@ export const SecretImportItem = ({
transition transition
}; };
const handleResyncSecretReplication = async () => {
if (resyncSecretReplication.isLoading) return;
try {
await resyncSecretReplication.mutateAsync({
id,
environment,
path: secretPath,
projectId: currentWorkspace?.id || ""
});
createNotification({
text: "Please refresh the dashboard to view changes",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to resync replication",
type: "error"
});
}
};
const handleRowClick = () => {
if (isReplication) {
onExpandReplicate(id);
} else {
setIsExpanded.toggle();
}
};
return ( return (
<> <>
<div <div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700" className={twMerge(
"group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700",
isReserved && "hidden"
)}
role="button" role="button"
ref={setNodeRef} ref={setNodeRef}
tabIndex={0} tabIndex={0}
style={style} style={style}
onClick={() => setIsExpanded.toggle()} onClick={handleRowClick}
onKeyDown={() => setIsExpanded.toggle()} onKeyDown={(e) => {
if (e.key === "Enter") {
handleRowClick();
}
}}
> >
<div className="flex w-12 items-center px-4 py-2 text-green-700"> <div className="flex w-12 items-center px-4 py-2 text-green-700">
<FontAwesomeIcon icon={faFileImport} /> <FontAwesomeIcon icon={faFileImport} />
</div> </div>
<div className="flex flex-grow items-center px-4 py-2"> <div className="flex flex-grow items-center px-4 py-2">
<EnvFolderIcon env={importEnvName || ""} secretPath={importEnvPath} /> <EnvFolderIcon
env={importEnv.slug || ""}
secretPath={secretImport?.importPath || ""}
// isReplication={isReplication}
/>
</div>
<div className="flex items-center space-x-4 px-4 py-2">
{lastReplicated && (
<Tooltip
position="left"
className="max-w-md whitespace-normal break-words"
content={
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
<div className="text-sm">Last Replication</div>
</div>
<div className="pl-5 text-left text-xs">
{lastReplicated
? format(new Date(lastReplicated), "yyyy-MM-dd, hh:mm aaa")
: "-"}
</div>
{!isReplicationSuccess && (
<>
<div className="mt-2 flex self-start">
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
<div className="text-sm">Fail reason</div>
</div>
<div className="pl-5 text-left text-xs">{replicationStatus}</div>
</>
)}
</div>
}
>
<div
className={twMerge(
"opacity-0 group-hover:opacity-100",
!isReplicationSuccess && "text-red-600"
)}
>
<FontAwesomeIcon icon={isReplicationSuccess ? faInfoCircle : faWarning} />
</div>
</Tooltip>
)}
{isReplication && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Resync replicated secrets"
>
{(isAllowed) => (
<IconButton
size="md"
colorSchema="primary"
variant="plain"
ariaLabel="expand"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
resyncSecretReplication.isLoading && "animate-spin opacity-100"
)}
isDisabled={!isAllowed}
onClick={handleResyncSecretReplication}
>
<FontAwesomeIcon icon={faRotate} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div> </div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<ProjectPermissionCan <ProjectPermissionCan
@@ -141,7 +281,7 @@ export const SecretImportItem = ({
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> </div>
{isExpanded && !isDragging && ( {!isReplication && (isReplicationExpand || isExpanded) && !isDragging && (
<td <td
colSpan={3} colSpan={3}
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`} className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
@@ -16,8 +16,10 @@ import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2"; import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api"; import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types"; import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { DecryptedSecret, WorkspaceEnv } from "@app/hooks/api/types"; import { DecryptedSecret, WorkspaceEnv } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretImportItem } from "./SecretImportItem"; import { SecretImportItem } from "./SecretImportItem";
@@ -50,7 +52,7 @@ export const computeImportedSecretRows = (
importSecrets[i].secrets.forEach((el) => { importSecrets[i].secrets.forEach((el) => {
overridenSec[el.key] = { overridenSec[el.key] = {
env: importSecrets[i].environmentInfo.name, env: importSecrets[i].environmentInfo.name,
secretPath: importSecrets[i].secretPath secretPath: formatReservedPaths(importSecrets[i].secretPath)
}; };
}); });
} }
@@ -90,7 +92,9 @@ export const SecretImportListView = ({
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecretImport" "deleteSecretImport"
] as const); ] as const);
const [replicationSecrets, setReplicationSecrets] = useState<Record<string, boolean>>({});
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, {}), useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}), useSensor(TouchSensor, {}),
@@ -149,6 +153,23 @@ export const SecretImportListView = ({
} }
}; };
const handleOpenReplicationSecrets = (replicationImportId: string) => {
const reservedImport = secretImports.find(
({ isReserved, importPath, importEnv }) =>
importEnv.slug === environment &&
isReserved &&
importPath ===
`${secretPath === "/" ? "" : secretPath}/${ReservedFolders.SecretReplication
}${replicationImportId}`
);
if (reservedImport) {
setReplicationSecrets((state) => ({
...state,
[reservedImport.id]: !state?.[reservedImport.id]
}));
}
};
return ( return (
<> <>
<DndContext <DndContext
@@ -159,17 +180,17 @@ export const SecretImportListView = ({
> >
<SortableContext items={items} strategy={verticalListSortingStrategy}> <SortableContext items={items} strategy={verticalListSortingStrategy}>
{items?.map((item) => { {items?.map((item) => {
const { importPath, importEnv, id } = item; // TODO(akhilmhdh): change this and pass this whole object instead of one by one
return ( return (
<SecretImportItem <SecretImportItem
searchTerm={searchTerm} searchTerm={searchTerm}
key={`imported-env-${id}`} key={`imported-env-${item.id}`}
id={id} isReplicationExpand={replicationSecrets?.[item.id]}
importEnvPath={importPath} onExpandReplicateSecrets={handleOpenReplicationSecrets}
importEnvName={importEnv.name} secretImport={item}
importedSecrets={computeImportedSecretRows( importedSecrets={computeImportedSecretRows(
importEnv.slug, item.importEnv.slug,
importPath, item.importPath,
importedSecrets, importedSecrets,
secrets secrets
)} )}
@@ -185,9 +206,8 @@ export const SecretImportListView = ({
isOpen={popUp.deleteSecretImport.isOpen} isOpen={popUp.deleteSecretImport.isOpen}
deleteKey="unlink" deleteKey="unlink"
title="Do you want to remove this secret import?" title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${ subTitle={`This will unlink secrets from environment ${(popUp.deleteSecretImport?.data as TSecretImport)?.importEnv
(popUp.deleteSecretImport?.data as TSecretImport)?.importEnv } of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`}
} of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)} onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete} onDeleteApproved={handleSecretImportDelete}
/> />
@@ -420,9 +420,8 @@ export const SecretItem = memo(
<Tooltip <Tooltip
content={ content={
secretReminderRepeatDays && secretReminderRepeatDays > 0 secretReminderRepeatDays && secretReminderRepeatDays > 0
? `Every ${secretReminderRepeatDays} day${ ? `Every ${secretReminderRepeatDays} day${Number(secretReminderRepeatDays) > 1 ? "s" : ""
Number(secretReminderRepeatDays) > 1 ? "s" : "" }
}
` `
: "Reminder" : "Reminder"
} }
@@ -492,7 +491,7 @@ export const SecretItem = memo(
ariaLabel="more" ariaLabel="more"
variant="plain" variant="plain"
size="md" size="md"
className="p-0 opacity-0 group-hover:opacity-100 h-5 w-4" className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)} onClick={() => onDetailViewSecret(secret)}
> >
<FontAwesomeSymbol <FontAwesomeSymbol
@@ -3,6 +3,7 @@ import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { import {
faCheck, faCheck,
faClock, faClock,
faClone,
faClose, faClose,
faCodeBranch, faCodeBranch,
faComment, faComment,
@@ -64,7 +65,8 @@ export enum FontAwesomeSpriteName {
More = "more", More = "more",
Override = "secret-override", Override = "secret-override",
Close = "close", Close = "close",
CheckedCircle = "check-circle" CheckedCircle = "check-circle",
ReplicatedSecretKey = "secret-replicated"
} }
// this is an optimization technique // this is an optimization technique
@@ -79,5 +81,6 @@ export const FontAwesomeSpriteSymbols = [
{ icon: faEllipsis, symbol: FontAwesomeSpriteName.More }, { icon: faEllipsis, symbol: FontAwesomeSpriteName.More },
{ icon: faCodeBranch, symbol: FontAwesomeSpriteName.Override }, { icon: faCodeBranch, symbol: FontAwesomeSpriteName.Override },
{ icon: faClose, symbol: FontAwesomeSpriteName.Close }, { icon: faClose, symbol: FontAwesomeSpriteName.Close },
{ icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle } { icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle },
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey }
]; ];
@@ -13,14 +13,16 @@ export const ShareSecretPage = () => {
<p className="text-bunker-300">Share secrets securely using a shareable link</p> <p className="text-bunker-300">Share secrets securely using a shareable link</p>
</div> </div>
<div className="flex w-max justify-center"> <div className="flex w-max justify-center">
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing"> <Link href="https://infisical.com/docs/documentation/platform/secret-sharing" passHref>
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"> <a target="_blank" rel="noopener noreferrer">
<div className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "} Documentation{" "}
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs" className="mb-[0.06rem] ml-1 text-xs"
/> />
</span> </div>
</a>
</Link> </Link>
</div> </div>
</div> </div>
@@ -9,9 +9,7 @@ import { AxiosError } from "axios";
import * as yup from "yup"; import * as yup from "yup";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
encryptSymmetric,
} from "@app/components/utilities/cryptography/crypto";
import { import {
Button, Button,
FormControl, FormControl,
@@ -49,22 +47,12 @@ const expirationUnitsAndActions = [
unit: "Weeks", unit: "Weeks",
action: (expiresAt: Date, expiresInValue: number) => action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7) expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7)
},
{
unit: "Months",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setMonth(expiresAt.getMonth() + expiresInValue)
},
{
unit: "Years",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setFullYear(expiresAt.getFullYear() + expiresInValue)
} }
]; ];
const schema = yup.object({ const schema = yup.object({
name: yup.string().max(100).required().label("Shared Secret Name"), value: yup.string().max(10000).required().label("Shared Secret Value"),
value: yup.string().max(1000).required().label("Shared Secret Value"), expiresAfterViews: yup.number().min(1).required().label("Expires After Views"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"), expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit") expiresInUnit: yup.string().required().label("Expiration Unit")
}); });
@@ -93,7 +81,7 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const [newSharedSecret, setnewSharedSecret] = useState(""); const [newSharedSecret, setnewSharedSecret] = useState("");
const hasSharedSecret = Boolean(newSharedSecret); const hasSharedSecret = Boolean(newSharedSecret);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({ const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false, initialState: false
}); });
const copyUrlToClipboard = () => { const copyUrlToClipboard = () => {
@@ -106,10 +94,14 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
}, [isUrlCopied]); }, [isUrlCopied]);
const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => { const onFormSubmit = async ({
value,
expiresInValue,
expiresInUnit,
expiresAfterViews
}: FormData) => {
try { try {
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
const key = crypto.randomBytes(16).toString("hex"); const key = crypto.randomBytes(16).toString("hex");
const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
const { ciphertext, iv, tag } = encryptSymmetric({ const { ciphertext, iv, tag } = encryptSymmetric({
@@ -117,25 +109,26 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
key key
}); });
const expiresAt = new Date(); const expiresAt = new Date();
const updateExpiresAt = expirationUnitsAndActions.find( const updateExpiresAt = expirationUnitsAndActions.find(
(item) => item.unit === expiresInUnit (item) => item.unit === expiresInUnit
)?.action; )?.action;
if (updateExpiresAt) { if (updateExpiresAt && expiresInValue) {
updateExpiresAt(expiresAt, expiresInValue); updateExpiresAt(expiresAt, expiresInValue);
} }
const { id } = await createSharedSecret.mutateAsync({ const { id } = await createSharedSecret.mutateAsync({
name,
encryptedValue: ciphertext, encryptedValue: ciphertext,
iv, iv,
tag, tag,
hashedHex, hashedHex,
expiresAt, expiresAt,
expiresAfterViews
}); });
setnewSharedSecret( setnewSharedSecret(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}` `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
hashedHex
)}-${encodeURIComponent(key)}`
); );
createNotification({ createNotification({
@@ -168,90 +161,100 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
setnewSharedSecret(""); setnewSharedSecret("");
}} }}
> >
<ModalContent <ModalContent
title="Share a Secret" title="Share a Secret"
subTitle="This link is only accessible once. Please share this link with intended recipients. " subTitle="This link is only accessible once. Please share this link with intended recipients. "
> >
{!hasSharedSecret ? ( {!hasSharedSecret ? (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Shared Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your secret identifier" />
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
name="value" name="value"
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Shared Secret Value" label="Shared Secret"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<SecretInput <SecretInput
isVisible isVisible
{...field} {...field}
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2" containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 min-h-[100px]"
/> />
</FormControl> </FormControl>
)} )}
/> />
<div className="flex w-full flex-row justify-end"> <div className="flex w-full flex-row">
<div className="w-3/5"> <div className="w-2/7 flex">
<Controller <Controller
control={control} control={control}
name="expiresInValue" name="expiresAfterViews"
defaultValue={1} defaultValue={6}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Expiration Value" className="mb-4 w-full"
label="Expires After Views"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText="Please enter a valid number of views"
> >
<Input {...field} type="number" min={0} /> <Input {...field} type="number" min={1} />
</FormControl> </FormControl>
)} )}
/> />
</div> </div>
<div className="w-2/5 pl-4"> <div className="w-1/7 flex items-center justify-center px-2">
<Controller <p className="px-4 text-sm text-gray-400">OR</p>
control={control} </div>
name="expiresInUnit" <div className="w-4/7 flex">
defaultValue={expirationUnitsAndActions[0].unit} <div className="flex w-full">
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( <div className="flex w-2/5 w-full justify-center">
<FormControl <Controller
label="Expiration Unit" control={control}
errorText={error?.message} name="expiresInValue"
isError={Boolean(error)} defaultValue={6}
> render={({ field, fieldState: { error } }) => (
<Select <FormControl
defaultValue={field.value} label="Expires after Time"
{...field} isError={Boolean(error)}
onValueChange={(e) => onChange(e)} errorText="Please enter a valid time duration"
className="w-full" >
> <Input {...field} type="number" min={0} />
{expirationUnitsAndActions.map(({ unit }) => ( </FormControl>
<SelectItem value={unit} key={unit}> )}
{unit} />
</SelectItem> </div>
))} <div className="flex w-3/5 w-full justify-center">
</Select> <Controller
</FormControl> control={control}
)} name="expiresInUnit"
/> defaultValue={expirationUnitsAndActions[0].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
</div> </div>
</div> </div>
<div className="mt-8 flex items-center"> <div className="flex items-center">
<Button <Button
className="mr-4" className="mr-4"
type="submit" type="submit"
@@ -8,7 +8,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : ""); const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
const isExpired = (expiresAt: Date): boolean => new Date(expiresAt) < new Date(); const isExpired = (expiresAt: Date | number | undefined): boolean => {
if (typeof expiresAt === "number") {
return expiresAt <= 0;
}
if (expiresAt instanceof Date) {
return new Date(expiresAt) < new Date();
}
return false;
};
const getValidityStatusText = (expiresAt: Date): string => const getValidityStatusText = (expiresAt: Date): string =>
isExpired(expiresAt) ? "Expired " : "Valid for "; isExpired(expiresAt) ? "Expired " : "Valid for ";
@@ -26,31 +34,38 @@ const timeAgo = (inputDate: Date, currentDate: Date): string => {
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365)); const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
if (elapsedYears > 0) { if (elapsedYears > 0) {
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${
}`; elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
} }
if (elapsedMonths > 0) { if (elapsedMonths > 0) {
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${
}`; elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
} }
if (elapsedWeeks > 0) { if (elapsedWeeks > 0) {
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${
}`; elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
} }
if (elapsedDays > 0) { if (elapsedDays > 0) {
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${
}`; elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
} }
if (elapsedHours > 0) { if (elapsedHours > 0) {
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${
}`; elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
} }
if (elapsedMinutes > 0) { if (elapsedMinutes > 0) {
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now" return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${
}`; elapsedMilliseconds >= 0 ? "ago" : "from now"
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`; }`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}; };
export const ShareSecretsRow = ({ export const ShareSecretsRow = ({
@@ -82,29 +97,36 @@ export const ShareSecretsRow = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isExpired(row.expiresAt)) { if (isExpired(row.expiresAt || row.expiresAfterViews)) {
onSecretExpiration(row.id); onSecretExpiration(row.id);
} }
}, [isExpired(row.expiresAt)]); }, [isExpired(row.expiresAt || row.expiresAfterViews)]);
return ( return (
<Tr key={row.id}> <Tr key={row.id}>
<Td>{row.name}</Td> <Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
<Td> <Td>
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p> <p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p> <p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
</Td> </Td>
<Td> <Td>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}> <>
{getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)} <p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
</>
</Td>
<Td>
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}>
{row.expiresAfterViews}
</p> </p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
</Td> </Td>
<Td> <Td>
<IconButton <IconButton
onClick={() => onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", { handlePopUpOpen("deleteSharedSecretConfirmation", {
name: row.name, name: "delete",
id: row.id id: row.id
}) })
} }
@@ -32,9 +32,13 @@ type Props = {
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => { export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
const { isLoading, data = [] } = useGetSharedSecrets(); const { isLoading, data = [] } = useGetSharedSecrets();
let tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()) let tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
const handleSecretExpiration = () => { const handleSecretExpiration = () => {
tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()); tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
}; };
return ( return (
@@ -42,7 +46,7 @@ export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Secret Name</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th>
<Th aria-label="button" /> <Th aria-label="button" />
</Tr> </Tr>
</THead> </THead>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo } from "react";
import Head from "next/head"; import Head from "next/head";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -21,58 +21,34 @@ export const ShareSecretPublicPage = () => {
} }
}, [id, publicKey]); }, [id, publicKey]);
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(id as string, hashedHex as string ); const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(
id as string,
hashedHex as string
);
const decryptedSecret = useMemo(() => { const decryptedSecret = useMemo(() => {
if (data && data.encryptedValue && publicKey) { if (data && data.encryptedValue && publicKey) {
const res = decryptSymmetric({ const res = decryptSymmetric({
ciphertext: data.encryptedValue, ciphertext: data.encryptedValue,
iv: data.iv, iv: data.iv,
tag: data.tag, tag: data.tag,
key, key
}); });
return res; return res;
} }
return ""; return "";
}, [data, publicKey]); }, [data, publicKey]);
const [timeLeft, setTimeLeft] = useState("");
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({ const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false, initialState: false
}); });
const millisecondsPerDay = 1000 * 60 * 60 * 24;
const millisecondsPerHour = 1000 * 60 * 60;
const millisecondsPerMinute = 1000 * 60;
useEffect(() => {
const updateTimer = () => {
if (data && data.expiresAt) {
const expirationTime = new Date(data.expiresAt).getTime();
const currentTime = new Date().getTime();
const timeDifference = expirationTime - currentTime;
if (timeDifference < 0) {
setTimeLeft("Expired");
} else {
const hoursRemaining = Math.floor((timeDifference % millisecondsPerDay) / millisecondsPerHour);
const minutesRemaining = Math.floor((timeDifference % millisecondsPerHour) / millisecondsPerMinute);
const secondsRemaining = Math.floor((timeDifference % millisecondsPerMinute) / 1000);
setTimeLeft(`${hoursRemaining}h ${minutesRemaining}m ${secondsRemaining}s`);
}
}
};
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [data?.expiresAt]);
useEffect(() => { useEffect(() => {
if (isUrlCopied) { if (isUrlCopied) {
setTimeout(() => setIsUrlCopied(false), 2000); setTimeout(() => setIsUrlCopied(false), 2000);
} }
}, [isUrlCopied]); }, [isUrlCopied]);
const copyUrlToClipboard = () => { const copyUrlToClipboard = () => {
navigator.clipboard.writeText(decryptedSecret); navigator.clipboard.writeText(decryptedSecret);
setIsUrlCopied(true); setIsUrlCopied(true);
@@ -95,14 +71,12 @@ export const ShareSecretPublicPage = () => {
<DragonMainImage /> <DragonMainImage />
<div className="m-4 flex flex-1 flex-col items-center justify-start md:m-0"> <div className="m-4 flex flex-1 flex-col items-center justify-start md:m-0">
<p className="mt-8 mb-2 text-xl font-semibold text-mineshaft-100 md:mt-20"> <p className="mt-8 mb-2 text-xl font-semibold text-mineshaft-100 md:mt-20">
Secret Details Shared Secret
</p> </p>
<div className="mb-16 rounded-lg border border-mineshaft-600 bg-mineshaft-900 md:p-8"> <div className="mb-4 rounded-lg md:p-2">
<SecretTable <SecretTable
isLoading={isLoading} isLoading={isLoading}
sharedSecret={data}
decryptedSecret={decryptedSecret} decryptedSecret={decryptedSecret}
timeLeft={timeLeft}
isUrlCopied={isUrlCopied} isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard} copyUrlToClipboard={copyUrlToClipboard}
/> />
@@ -1,96 +1,44 @@
import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import { EmptyState, IconButton, SecretInput, Td, Tr } from "@app/components/v2";
EmptyState,
IconButton,
SecretInput,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
type Props = { type Props = {
isLoading: boolean; isLoading: boolean;
sharedSecret?: TViewSharedSecretResponse;
decryptedSecret: string; decryptedSecret: string;
timeLeft: string;
isUrlCopied: boolean; isUrlCopied: boolean;
copyUrlToClipboard: () => void; copyUrlToClipboard: () => void;
}; };
export const SecretTable = ({ export const SecretTable = ({
isLoading, isLoading,
sharedSecret,
decryptedSecret, decryptedSecret,
timeLeft,
isUrlCopied, isUrlCopied,
copyUrlToClipboard copyUrlToClipboard
}: Props) => { }: Props) => (
return ( <div className="flex items-center rounded border border-solid border-mineshaft-700 bg-mineshaft-800 p-4">
<TableContainer> {isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
<Table> {!isLoading && !decryptedSecret && (
<THead> <Tr>
<Tr> <Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<Th>Name</Th> <EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
<Th>Value</Th> </Td>
<Th>Valid Until</Th> </Tr>
</Tr> )}
</THead> {!isLoading && decryptedSecret && (
<TBody> <>
{!isLoading && sharedSecret && decryptedSecret && ( <div className="min-w-[12rem] max-w-[20rem] flex-1 break-words pr-4">
<Tr key={sharedSecret.name}> <SecretInput isVisible value={decryptedSecret} readOnly />
<Td>{sharedSecret.name}</Td> </div>
<Td> <IconButton
<div className="flex items-center md:space-x-2"> ariaLabel="copy to clipboard"
<div className="max-w-[20rem] flex-1 break-words"> onClick={copyUrlToClipboard}
<SecretInput className="rounded p-2 hover:bg-gray-700"
isVisible size="xs"
value={decryptedSecret} >
readOnly <FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
/> </IconButton>
</div> </>
<IconButton )}
ariaLabel="copy to clipboard" </div>
onClick={copyUrlToClipboard} );
className="rounded p-2 hover:bg-gray-700"
size="xs"
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
</div>
</Td>
<Td>{timeLeft}</Td>
</Tr>
)}
{isLoading && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
Loading...
</Td>
</Tr>
)}
{!isLoading && !sharedSecret && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No such secret is shared yet!" icon={faKey} />
</Td>
</Tr>
)}
{!isLoading && sharedSecret && !decryptedSecret && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="Invalid URL to fetch the Secret!" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
+2 -2
View File
@@ -13,9 +13,9 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: v0.5.1 version: v0.5.2
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "v0.5.0" appVersion: "v0.5.2"
+1 -1
View File
@@ -32,7 +32,7 @@ controllerManager:
- ALL - ALL
image: image:
repository: infisical/kubernetes-operator repository: infisical/kubernetes-operator
tag: v0.5.1 # fixed to prevent accidental upgrade tag: v0.5.2 # fixed to prevent accidental upgrade
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
+4
View File
@@ -156,6 +156,7 @@ func CallGetDecryptedSecretsV3(httpClient *resty.Client, request GetDecryptedSec
R(). R().
SetResult(&decryptedSecretsResponse). SetResult(&decryptedSecretsResponse).
SetHeader("User-Agent", USER_AGENT_NAME). SetHeader("User-Agent", USER_AGENT_NAME).
SetQueryParam("include_imports", "true").
SetQueryParam("secretPath", request.SecretPath). SetQueryParam("secretPath", request.SecretPath).
SetQueryParam("workspaceSlug", request.ProjectSlug). SetQueryParam("workspaceSlug", request.ProjectSlug).
SetQueryParam("environment", request.Environment) SetQueryParam("environment", request.Environment)
@@ -163,6 +164,9 @@ func CallGetDecryptedSecretsV3(httpClient *resty.Client, request GetDecryptedSec
if request.Recursive { if request.Recursive {
req.SetQueryParam("recursive", "true") req.SetQueryParam("recursive", "true")
} }
if request.ExpandSecretReferences {
req.SetQueryParam("expandSecretReferences", "true")
}
response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", API_HOST_URL)) response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", API_HOST_URL))

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