feat(infisical-pg): completed root folder level secret, secret import and folder

This commit is contained in:
Akhil Mohan
2023-12-21 20:59:42 +05:30
parent 8a6ab7f2f6
commit 0e1191f2ea
70 changed files with 3105 additions and 278 deletions

View File

@@ -19,6 +19,7 @@
"@fastify/swagger": "^8.12.0",
"@fastify/swagger-ui": "^1.10.1",
"@ucast/mongo2js": "^1.3.4",
"argon2": "^0.31.2",
"axios": "^1.6.2",
"axios-retry": "^4.0.0",
"bcrypt": "^5.1.1",
@@ -38,6 +39,8 @@
"pg": "^8.11.3",
"picomatch": "^3.0.1",
"pino": "^8.16.2",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.0"
},
@@ -940,6 +943,14 @@
"node": ">= 8"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"engines": {
"node": ">=10"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -2269,6 +2280,25 @@
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
},
"node_modules/argon2": {
"version": "0.31.2",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.2.tgz",
"integrity": "sha512-QSnJ8By5Mth60IEte45w9Y7v6bWcQw3YhRtJKKN8oNCxnTLDiv/AXXkDPf2srTMfxFVn3QJdVv2nhXESsUa+Yg==",
"hasInstallScript": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"@phc/format": "^1.0.0",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/argon2/node_modules/node-addon-api": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz",
"integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA=="
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -8571,6 +8601,16 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"node_modules/tweetnacl-util": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz",
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -72,6 +72,7 @@
"@fastify/swagger": "^8.12.0",
"@fastify/swagger-ui": "^1.10.1",
"@ucast/mongo2js": "^1.3.4",
"argon2": "^0.31.2",
"axios": "^1.6.2",
"axios-retry": "^4.0.0",
"bcrypt": "^5.1.1",
@@ -91,6 +92,8 @@
"pg": "^8.11.3",
"picomatch": "^3.0.1",
"pino": "^8.16.2",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.0"
}

View File

@@ -3,7 +3,7 @@ import { execSync } from "child_process";
import path from "path";
import promptSync from "prompt-sync";
const prompt = promptSync();
const prompt = promptSync({ sigint: true });
const migrationName = prompt("Enter name for migration: ");

View File

@@ -4,7 +4,7 @@ import { readdirSync } from "fs";
import path from "path";
import promptSync from "prompt-sync";
const prompt = promptSync();
const prompt = promptSync({ sigint: true });
const migrationName = prompt("Enter name for seedfile: ");
const fileCounter = readdirSync(path.join(__dirname, "../src/db/seed")).length || 1;

View File

@@ -5,7 +5,7 @@ import knex from "knex";
import { writeFileSync } from "fs";
import promptSync from "prompt-sync";
const prompt = promptSync();
const prompt = promptSync({ sigint: true });
dotenv.config({
path: path.join(__dirname, "../.env"),

View File

@@ -15,6 +15,9 @@ import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env
import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service";
import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { TProjectRoleServiceFactory } from "@app/services/project-role/project-role-service";
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
import { TAuthTokenServiceFactory } from "@app/services/token/token-service";
import { TUserDalFactory } from "@app/services/user/user-dal";
@@ -64,6 +67,9 @@ declare module "fastify" {
projectEnv: TProjectEnvServiceFactory;
projectKey: TProjectKeyServiceFactory;
projectRole: TProjectRoleServiceFactory;
secret: TSecretServiceFactory;
secretImport: TSecretImportServiceFactory;
folder: TSecretFolderServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data

View File

@@ -40,6 +40,27 @@ import {
TProjects,
TProjectsInsert,
TProjectsUpdate,
TSecretBlindIndexes,
TSecretBlindIndexesInsert,
TSecretBlindIndexesUpdate,
TSecretFolders,
TSecretFoldersInsert,
TSecretFoldersUpdate,
TSecretImports,
TSecretImportsInsert,
TSecretImportsUpdate,
TSecrets,
TSecretsInsert,
TSecretsUpdate,
TSecretTagJunction,
TSecretTagJunctionInsert,
TSecretTagJunctionUpdate,
TSecretTags,
TSecretTagsInsert,
TSecretTagsUpdate,
TSecretVersions,
TSecretVersionsInsert,
TSecretVersionsUpdate,
TSuperAdmin,
TSuperAdminInsert,
TSuperAdminUpdate,
@@ -125,5 +146,36 @@ declare module "knex/types/tables" {
TProjectKeysInsert,
TProjectKeysUpdate
>;
[TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>;
[TableName.SecretBlindIndex]: Knex.CompositeTableType<
TSecretBlindIndexes,
TSecretBlindIndexesInsert,
TSecretBlindIndexesUpdate
>;
[TableName.SecretVersion]: Knex.CompositeTableType<
TSecretVersions,
TSecretVersionsInsert,
TSecretVersionsUpdate
>;
[TableName.SecretFolder]: Knex.CompositeTableType<
TSecretFolders,
TSecretFoldersInsert,
TSecretFoldersUpdate
>;
[TableName.SecretTag]: Knex.CompositeTableType<
TSecretTags,
TSecretTagsInsert,
TSecretTagsUpdate
>;
[TableName.SecretImport]: Knex.CompositeTableType<
TSecretImports,
TSecretImportsInsert,
TSecretImportsUpdate
>;
[TableName.JnSecretTag]: Knex.CompositeTableType<
TSecretTagJunction,
TSecretTagJunctionInsert,
TSecretTagJunctionUpdate
>;
}
}

View File

@@ -21,8 +21,14 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.string("slug").notNullable();
t.integer("position").notNullable();
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
// this will ensure ever env has its position
t.unique(["projectId", "position"], {
indexName: "env_pos_composite_uniqe",
deferrable: "deferred"
});
t.timestamps(true, true, true);
});
}
@@ -34,9 +40,9 @@ export async function up(knex: Knex): Promise<void> {
t.text("nonce").notNullable();
t.uuid("receiverId").notNullable();
t.foreign("receiverId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.uuid("senderId").notNullable();
t.uuid("senderId");
// if sender is deleted just don't do anything to this record
t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("NO ACTION");
t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("SET NULL");
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.timestamps(true, true, true);

View File

@@ -0,0 +1,25 @@
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.SecretFolder))) {
await knex.schema.createTable(TableName.SecretFolder, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.integer("version").defaultTo(1);
t.timestamps(true, true, true);
t.uuid("envId").notNullable();
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
t.uuid("parentId");
t.foreign("parentId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.SecretFolder);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretFolder);
await dropOnUpdateTrigger(knex, TableName.SecretFolder);
}

View File

@@ -0,0 +1,30 @@
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.SecretImport))) {
await knex.schema.createTable(TableName.SecretImport, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.integer("version").defaultTo(1);
t.string("importPath").notNullable();
t.uuid("importEnv").notNullable();
t.foreign("importEnv").references("id").inTable(TableName.Environment).onDelete("CASCADE");
t.integer("position").notNullable();
t.timestamps(true, true, true);
t.uuid("folderId").notNullable();
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.unique(["folderId", "position"], {
indexName: "import_pos_composite_uniqe",
deferrable: "deferred"
});
});
}
await createOnUpdateTrigger(knex, TableName.SecretImport);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretImport);
await dropOnUpdateTrigger(knex, TableName.SecretImport);
}

View File

@@ -0,0 +1,26 @@
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.SecretTag))) {
await knex.schema.createTable(TableName.SecretTag, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.string("slug").notNullable();
t.string("tagColor").notNullable();
t.timestamps(true, true, true);
t.uuid("createdBy").notNullable();
t.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("NO ACTION");
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.SecretTag);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretTag);
await dropOnUpdateTrigger(knex, TableName.SecretTag);
}

View File

@@ -0,0 +1,65 @@
import { Knex } from "knex";
import { SecretEncryptionAlgo, SecretKeyEncoding, SecretType, TableName } from "../schemas";
import { createJunctionTable, createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.SecretBlindIndex))) {
await knex.schema.createTable(TableName.SecretBlindIndex, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.text("encryptedSaltCipherText").notNullable();
t.text("saltIV").notNullable();
t.text("saltTag").notNullable();
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);
t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8);
t.uuid("projectId").notNullable().unique();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.SecretBlindIndex);
if (!(await knex.schema.hasTable(TableName.Secret))) {
await knex.schema.createTable(TableName.Secret, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.integer("version").defaultTo(1);
t.string("type").notNullable().defaultTo(SecretType.Shared);
// t.text("secretKeyHash").notNullable();
// t.text("secretValueHash");
// t.text("secretCommentHash");
t.text("secretBlindIndex").notNullable();
t.text("secretKeyCiphertext").notNullable();
t.text("secretKeyIV").notNullable();
t.text("secretKeyTag").notNullable();
t.text("secretValueCiphertext").notNullable();
t.text("secretValueIV").notNullable(); // symmetric encryption
t.text("secretValueTag").notNullable();
t.text("secretCommentCiphertext");
t.text("secretCommentIV");
t.text("secretCommentTag");
t.string("secretReminderNotice");
t.integer("secretReminderRepeatDays");
t.boolean("skipMultilineEncoding").defaultTo(false);
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);
t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8);
t.jsonb("metadata");
t.uuid("userId");
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.uuid("folderId").notNullable();
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.Secret);
// many to many relation between tags
await createJunctionTable(knex, TableName.JnSecretTag, TableName.Secret, TableName.SecretTag);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SecretBlindIndex);
await dropOnUpdateTrigger(knex, TableName.SecretBlindIndex);
await knex.schema.dropTableIfExists(TableName.JnSecretTag);
await knex.schema.dropTableIfExists(TableName.Secret);
await dropOnUpdateTrigger(knex, TableName.Secret);
}

View File

@@ -0,0 +1,54 @@
import { Knex } from "knex";
import { SecretEncryptionAlgo, SecretKeyEncoding, SecretType, TableName } from "../schemas";
import { createJunctionTable, createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.SecretVersion))) {
await knex.schema.createTable(TableName.SecretVersion, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.integer("version").defaultTo(1);
t.string("type").notNullable().defaultTo(SecretType.Shared);
// t.text("secretKeyHash").notNullable();
// t.text("secretValueHash");
// t.text("secretCommentHash");
t.text("secretBlindIndex").notNullable();
t.text("secretKeyCiphertext").notNullable();
t.text("secretKeyIV").notNullable();
t.text("secretKeyTag").notNullable();
t.text("secretValueCiphertext").notNullable();
t.text("secretValueIV").notNullable(); // symmetric encryption
t.text("secretValueTag").notNullable();
t.text("secretCommentCiphertext");
t.text("secretCommentIV");
t.text("secretCommentTag");
t.string("secretReminderNotice");
t.integer("secretReminderRepeatDays");
t.boolean("skipMultilineEncoding").defaultTo(false);
t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM);
t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8);
t.jsonb("metadata");
t.uuid("secretId");
t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL");
t.uuid("userId");
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.uuid("folderId").notNullable();
t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.SecretVersion);
// many to many relation between tags
await createJunctionTable(
knex,
TableName.JnSecretVersionTag,
TableName.SecretVersion,
TableName.SecretTag
);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.JnSecretVersionTag);
await knex.schema.dropTableIfExists(TableName.SecretVersion);
await dropOnUpdateTrigger(knex, TableName.SecretVersion);
}

View File

@@ -12,6 +12,13 @@ export * from "./project-keys";
export * from "./project-memberships";
export * from "./project-roles";
export * from "./projects";
export * from "./secret-blind-indexes";
export * from "./secret-folders";
export * from "./secret-imports";
export * from "./secret-tag-junction";
export * from "./secret-tags";
export * from "./secret-versions";
export * from "./secrets";
export * from "./super-admin";
export * from "./user-actions";
export * from "./user-encryption-keys";

View File

@@ -17,7 +17,15 @@ export enum TableName {
Environment = "project_environments",
ProjectMembership = "project_memberships",
ProjectRoles = "project_roles",
ProjectKeys = "project_keys"
ProjectKeys = "project_keys",
Secret = "secrets",
SecretBlindIndex = "secret_blind_indexes",
SecretVersion = "secret_versions",
SecretFolder = "secret_folders",
SecretImport = "secret_imports",
SecretTag = "secret_tags",
JnSecretTag = "secret_tag_junction",
JnSecretVersionTag = "secret_version_tag_junction"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
@@ -58,3 +66,8 @@ export enum SecretKeyEncoding {
BASE64 = "base64",
HEX = "hex"
}
export enum SecretType {
Shared = "shared",
Personal = "personal"
}

View File

@@ -11,6 +11,7 @@ export const ProjectEnvironmentsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
slug: z.string(),
position: z.number(),
projectId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),

View File

@@ -0,0 +1,24 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretBlindIndexesSchema = z.object({
id: z.string().uuid(),
encryptedSaltCipherText: z.string(),
saltIV: z.string(),
saltTag: z.string(),
algorithm: z.string().default('aes-256-gcm'),
keyEncoding: z.string().default('utf8'),
projectId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TSecretBlindIndexes = z.infer<typeof SecretBlindIndexesSchema>;
export type TSecretBlindIndexesInsert = Omit<TSecretBlindIndexes, TImmutableDBKeys>;
export type TSecretBlindIndexesUpdate = Partial<Omit<TSecretBlindIndexes, TImmutableDBKeys>>;

View File

@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretFoldersSchema = z.object({
id: z.string().uuid(),
name: z.string(),
version: z.number().default(1).nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
envId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(),
});
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;
export type TSecretFoldersInsert = Omit<TSecretFolders, TImmutableDBKeys>;
export type TSecretFoldersUpdate = Partial<Omit<TSecretFolders, TImmutableDBKeys>>;

View File

@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretImportsSchema = z.object({
id: z.string().uuid(),
version: z.number().default(1).nullable().optional(),
importPath: z.string(),
importEnv: z.string().uuid(),
position: z.number(),
createdAt: z.date(),
updatedAt: z.date(),
folderId: z.string().uuid(),
});
export type TSecretImports = z.infer<typeof SecretImportsSchema>;
export type TSecretImportsInsert = Omit<TSecretImports, TImmutableDBKeys>;
export type TSecretImportsUpdate = Partial<Omit<TSecretImports, TImmutableDBKeys>>;

View File

@@ -0,0 +1,18 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretTagJunctionSchema = z.object({
id: z.string().uuid(),
secretsId: z.string().uuid(),
secret_tagsId: z.string().uuid(),
});
export type TSecretTagJunction = z.infer<typeof SecretTagJunctionSchema>;
export type TSecretTagJunctionInsert = Omit<TSecretTagJunction, TImmutableDBKeys>;
export type TSecretTagJunctionUpdate = Partial<Omit<TSecretTagJunction, TImmutableDBKeys>>;

View File

@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretTagsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
slug: z.string(),
tagColor: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string().uuid(),
projectId: z.string().uuid(),
});
export type TSecretTags = z.infer<typeof SecretTagsSchema>;
export type TSecretTagsInsert = Omit<TSecretTags, TImmutableDBKeys>;
export type TSecretTagsUpdate = Partial<Omit<TSecretTags, TImmutableDBKeys>>;

View File

@@ -0,0 +1,39 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretVersionsSchema = z.object({
id: z.string().uuid(),
version: z.number().default(1).nullable().optional(),
type: z.string().default('shared'),
secretBlindIndex: z.string(),
secretKeyCiphertext: z.string(),
secretKeyIV: z.string(),
secretKeyTag: z.string(),
secretValueCiphertext: z.string(),
secretValueIV: z.string(),
secretValueTag: z.string(),
secretCommentCiphertext: z.string().nullable().optional(),
secretCommentIV: z.string().nullable().optional(),
secretCommentTag: z.string().nullable().optional(),
secretReminderNotice: z.string().nullable().optional(),
secretReminderRepeatDays: z.number().nullable().optional(),
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
algorithm: z.string().default('aes-256-gcm'),
keyEncoding: z.string().default('utf8'),
metadata: z.unknown().nullable().optional(),
secretId: z.string().uuid(),
userId: z.string().uuid().nullable().optional(),
folderId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TSecretVersions = z.infer<typeof SecretVersionsSchema>;
export type TSecretVersionsInsert = Omit<TSecretVersions, TImmutableDBKeys>;
export type TSecretVersionsUpdate = Partial<Omit<TSecretVersions, TImmutableDBKeys>>;

View File

@@ -0,0 +1,38 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretsSchema = z.object({
id: z.string().uuid(),
version: z.number().default(1).nullable().optional(),
type: z.string().default('shared'),
secretBlindIndex: z.string(),
secretKeyCiphertext: z.string(),
secretKeyIV: z.string(),
secretKeyTag: z.string(),
secretValueCiphertext: z.string(),
secretValueIV: z.string(),
secretValueTag: z.string(),
secretCommentCiphertext: z.string().nullable().optional(),
secretCommentIV: z.string().nullable().optional(),
secretCommentTag: z.string().nullable().optional(),
secretReminderNotice: z.string().nullable().optional(),
secretReminderRepeatDays: z.number().nullable().optional(),
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
algorithm: z.string().default('aes-256-gcm'),
keyEncoding: z.string().default('utf8'),
metadata: z.unknown().nullable().optional(),
userId: z.string().uuid().nullable().optional(),
folderId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TSecrets = z.infer<typeof SecretsSchema>;
export type TSecretsInsert = Omit<TSecrets, TImmutableDBKeys>;
export type TSecretsUpdate = Partial<Omit<TSecrets, TImmutableDBKeys>>;

View File

@@ -1,15 +1,17 @@
import { Knex } from "knex";
import { TableName } from "./schemas";
export const createJunctionTable = (
knex: Knex,
tableName: string,
table1Name: string,
table2Name: string
tableName: TableName,
table1Name: TableName,
table2Name: TableName
) =>
knex.schema.createTable(tableName, (table) => {
table.increments(); // Primary key
table.integer(`${table1Name}Id`).unsigned().notNullable(); // Foreign key for table1
table.integer(`${table2Name}Id`).unsigned().notNullable(); // Foreign key for table2
table.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
table.uuid(`${table1Name}Id`).unsigned().notNullable(); // Foreign key for table1
table.uuid(`${table2Name}Id`).unsigned().notNullable(); // Foreign key for table2
table.foreign(`${table1Name}Id`).references("id").inTable(table2Name);
table.foreign(`${table2Name}Id`).references("id").inTable(table1Name);
});

View File

@@ -0,0 +1,184 @@
import crypto from "node:crypto";
import * as argon2 from "argon2";
import nacl from "tweetnacl";
import naclUtils from "tweetnacl-util";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
export type TDecryptSymmetricInput = {
ciphertext: string;
iv: string;
tag: string;
key: string;
};
export const IV_BYTES_SIZE = 12;
export const BLOCK_SIZE_BYTES_16 = 16;
export const decryptSymmetric = ({ ciphertext, iv, tag, key }: TDecryptSymmetricInput): string => {
const secretKey = crypto.createSecretKey(key, "base64");
const decipher = crypto.createDecipheriv(
SecretEncryptionAlgo.AES_256_GCM,
secretKey,
Buffer.from(iv, "base64")
);
decipher.setAuthTag(Buffer.from(tag, "base64"));
let cleartext = decipher.update(ciphertext, "base64", "utf8");
cleartext += decipher.final("utf8");
return cleartext;
};
export const encryptSymmetric = (plaintext: string, key: string) => {
const iv = crypto.randomBytes(IV_BYTES_SIZE);
const secretKey = crypto.createSecretKey(key, "base64");
const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, secretKey, iv);
let ciphertext = cipher.update(plaintext, "utf8", "base64");
ciphertext += cipher.final("base64");
return {
ciphertext,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64")
};
};
export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string) => {
const iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16);
const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
let ciphertext = cipher.update(plaintext, "utf8", "base64");
ciphertext += cipher.final("base64");
return {
ciphertext,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64")
};
};
export const decryptSymmetric128BitHexKeyUTF8 = ({
ciphertext,
iv,
tag,
key
}: TDecryptSymmetricInput): string => {
const decipher = crypto.createDecipheriv(
SecretEncryptionAlgo.AES_256_GCM,
key,
Buffer.from(iv, "base64")
);
decipher.setAuthTag(Buffer.from(tag, "base64"));
let cleartext = decipher.update(ciphertext, "base64", "utf8");
cleartext += decipher.final("utf8");
return cleartext;
};
export const encryptAsymmetric = (plaintext: string, publicKey: string, privateKey: string) => {
const nonce = nacl.randomBytes(24);
const ciphertext = nacl.box(
naclUtils.decodeUTF8(plaintext),
nonce,
naclUtils.decodeBase64(publicKey),
naclUtils.decodeBase64(privateKey)
);
return {
ciphertext: naclUtils.encodeBase64(ciphertext),
nonce: naclUtils.encodeBase64(nonce)
};
};
export type TDecryptAsymmetricInput = {
ciphertext: string;
nonce: string;
publicKey: string;
privateKey: string;
};
export const decryptAsymmetric = ({
ciphertext,
nonce,
publicKey,
privateKey
}: TDecryptAsymmetricInput) => {
const plaintext: Uint8Array | null = nacl.box.open(
naclUtils.decodeBase64(ciphertext),
naclUtils.decodeBase64(nonce),
naclUtils.decodeBase64(publicKey),
naclUtils.decodeBase64(privateKey)
);
if (plaintext == null) throw Error("Invalid ciphertext or keys");
return naclUtils.encodeUTF8(plaintext);
};
export type TGenSecretBlindIndex = {
secretName: string;
keyEncoding: SecretKeyEncoding;
rootEncryptionKey?: string;
encryptionKey?: string;
iv: string;
tag: string;
ciphertext: string;
};
export const buildSecretBlindIndexFromName = async ({
secretName,
ciphertext,
keyEncoding,
iv,
tag,
encryptionKey,
rootEncryptionKey
}: TGenSecretBlindIndex) => {
if (!encryptionKey && !rootEncryptionKey) throw new Error("Missing secret blind index key");
let salt = "";
if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) {
salt = decryptSymmetric({ iv, ciphertext, key: rootEncryptionKey, tag });
} else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) {
salt = decryptSymmetric128BitHexKeyUTF8({ iv, ciphertext, key: encryptionKey, tag });
}
if (!salt) throw new Error("Missing secret blind index key");
const secretBlindIndex = await argon2.hash(secretName, {
type: argon2.argon2id,
salt: Buffer.from(salt, "base64"),
saltLength: 16, // default 16 bytes
memoryCost: 65536, // default pool of 64 MiB per thread.
hashLength: 32,
parallelism: 1,
raw: true
});
return secretBlindIndex.toString("base64");
};
export const createSecretBlindIndex = (rootEncryptionKey?: string, encryptionKey?: string) => {
if (!encryptionKey && !rootEncryptionKey) throw new Error("Atleast one encryption key needed");
const salt = crypto.randomBytes(16).toString("base64");
if (rootEncryptionKey) {
const data = encryptSymmetric(salt, rootEncryptionKey);
return {
...data,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.BASE64
};
}
if (encryptionKey) {
const data = encryptSymmetric128BitHexKeyUTF8(salt, encryptionKey);
return {
...data,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
};
}
throw new Error("Failed to generate blind index due to encryption key missing");
};

View File

@@ -1 +1,11 @@
export {
buildSecretBlindIndexFromName,
createSecretBlindIndex,
decryptAsymmetric,
decryptSymmetric,
decryptSymmetric128BitHexKeyUTF8,
encryptAsymmetric,
encryptSymmetric,
encryptSymmetric128BitHexKeyUTF8
} from "./encryption";
export { generateSrpServerKey, srpCheckClientProof } from "./srp";

View File

@@ -28,9 +28,9 @@ export class BadRequestError extends Error {
error: unknown;
constructor({ name, error, message }: { message?: string; name: string; error?: unknown }) {
constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) {
super(message ?? "The request is invalid");
this.name = name;
this.name = name || "";
this.error = error;
}
}

View File

@@ -1,8 +1,6 @@
import { Knex } from "knex";
import { Tables } from "knex/types/tables";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "../errors";
export * from "./join";
@@ -19,7 +17,7 @@ export const withTransaction = <K extends object>(db: Knex, dal: K) => ({
// What is ormify
// It is to inject typical operations like find, findOne, update, delete, create
// This will avoid writing most common ones each time
export const ormify = <DbOps extends object, Tname extends TableName>(
export const ormify = <DbOps extends object, Tname extends keyof Tables>(
db: Knex,
tableName: Tname,
dal?: DbOps

View File

@@ -25,7 +25,6 @@ export const mergeOneToManyRelation = <
prevPkId = pk;
prevPkIndex += 1;
}
console.log(prevPkIndex, prevPkId);
groupedRecord[prevPkIndex][childKey].push(childMapper(row));
}
return groupedRecord;

View File

@@ -28,6 +28,14 @@ import { projectMembershipDalFactory } from "@app/services/project-membership/pr
import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { projectRoleDalFactory } from "@app/services/project-role/project-role-dal";
import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service";
import { secretBlindIndexDalFactory } from "@app/services/secret/secret-blind-index-dal";
import { secretDalFactory } from "@app/services/secret/secret-dal";
import { secretServiceFactory } from "@app/services/secret/secret-service";
import { secretVersionDalFactory } from "@app/services/secret/secret-version-dal";
import { secretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal";
import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
import { secretImportDalFactory } from "@app/services/secret-import/secret-import-dal";
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { superAdminDalFactory } from "@app/services/super-admin/super-admin-dal";
import { superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
@@ -35,10 +43,10 @@ import { userDalFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service";
import { injectIdentity } from "../plugins/auth/inject-identity";
import { injectPermission } from "../plugins/auth/inject-permission";
import { registerV1Routes } from "./v1";
import { registerV2Routes } from "./v2";
import { registerV3Routes } from "./v3";
import { injectPermission } from "../plugins/auth/inject-permission";
export const registerRoutes = async (
server: FastifyZodProvider,
@@ -60,6 +68,12 @@ export const registerRoutes = async (
const projectEnvDal = projectEnvDalFactory(db);
const projectKeyDal = projectKeyDalFactory(db);
const secretDal = secretDalFactory(db);
const folderDal = secretFolderDalFactory(db);
const secretImportDal = secretImportDalFactory(db);
const secretVersionDal = secretVersionDalFactory(db);
const secretBlindIndexDal = secretBlindIndexDalFactory(db);
// ee db layer ops
const permissionDal = permissionDalFactory(db);
@@ -104,8 +118,10 @@ export const registerRoutes = async (
const projectService = projectServiceFactory({
permissionService,
projectDal,
secretBlindIndexDal,
projectEnvDal,
projectMembershipDal
projectMembershipDal,
folderDal
});
const projectMembershipService = projectMembershipServiceFactory({
projectMembershipDal,
@@ -125,6 +141,25 @@ export const registerRoutes = async (
});
const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDal });
const secretService = secretServiceFactory({
folderDal,
secretVersionDal,
secretBlindIndexDal,
permissionService,
secretDal
});
const folderService = secretFolderServiceFactory({
permissionService,
folderDal,
projectEnvDal
});
const secretImportService = secretImportServiceFactory({
projectEnvDal,
folderDal,
permissionService,
secretImportDal
});
await superAdminService.initServerCfg();
// inject all services
server.decorate<FastifyZodProvider["services"]>("services", {
@@ -142,7 +177,10 @@ export const registerRoutes = async (
projectMembership: projectMembershipService,
projectKey: projectKeyService,
projectEnv: projectEnvService,
projectRole: projectRoleService
projectRole: projectRoleService,
secret: secretService,
folder: folderService,
secretImport: secretImportService
});
server.decorate<FastifyZodProvider["store"]>("store", {
@@ -169,6 +207,7 @@ export const registerRoutes = async (
},
handler: () => {
const appCfg = getConfig();
return {
date: new Date(),
message: "Ok" as const,

View File

@@ -7,6 +7,8 @@ import { registerProjectEnvRouter } from "./project-env-router";
import { registerProjectKeyRouter } from "./project-key-router";
import { registerProjectMembershipRouter } from "./project-membership-router";
import { registerProjectRouter } from "./project-router";
import { registerSecretFolderRouter } from "./secret-folder-router";
import { registerSecretImportRouter } from "./secret-import-router";
import { registerSsoRouter } from "./sso-router";
import { registerUserActionRouter } from "./user-action-router";
import { registerUserRouter } from "./user-router";
@@ -20,6 +22,8 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/user" });
await server.register(registerInviteOrgRouter, { prefix: "/invite-org" });
await server.register(registerUserActionRouter, { prefix: "/user-action" });
await server.register(registerSecretImportRouter, { prefix: "/secret-imports" });
await server.register(registerSecretFolderRouter, { prefix: "/folders" });
await server.register(
async (projectServer) => {

View File

@@ -0,0 +1,121 @@
import { z } from "zod";
import { SecretFoldersSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSecretFolderRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
method: "POST",
schema: {
body: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
name: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
folder: SecretFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const folder = await server.services.folder.createFolder({
actorId: req.permission.id,
actor: req.permission.type,
...req.body
});
return { folder };
}
});
server.route({
url: "/:folderId",
method: "PATCH",
schema: {
params: z.object({
folderId: z.string()
}),
body: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
name: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
folder: SecretFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const folder = await server.services.folder.updateFolder({
actorId: req.permission.id,
actor: req.permission.type,
...req.body,
id: req.params.folderId
});
return { folder };
}
});
server.route({
url: "/:folderId",
method: "DELETE",
schema: {
params: z.object({
folderId: z.string()
}),
body: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
folder: SecretFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const folder = await server.services.folder.deleteFolder({
actorId: req.permission.id,
actor: req.permission.type,
...req.body,
id: req.params.folderId
});
return { folder };
}
});
server.route({
url: "/",
method: "GET",
schema: {
querystring: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
folders: SecretFoldersSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const folders = await server.services.folder.getFolders({
actorId: req.permission.id,
actor: req.permission.type,
...req.query
});
return { folders };
}
});
};

View File

@@ -0,0 +1,152 @@
import { z } from "zod";
import { SecretImportsSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSecretImportRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
method: "POST",
schema: {
body: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
path: z.string().trim().default("/"),
import: z.object({
environment: z.string().trim(),
path: z.string().trim()
})
}),
response: {
200: z.object({
message: z.string(),
secretImport: SecretImportsSchema.omit({ importEnv: true }).merge(
z.object({
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secretImport = await server.services.secretImport.createImport({
actorId: req.permission.id,
actor: req.permission.type,
...req.body,
data: req.body.import
});
return { message: "Successfully created secret import", secretImport };
}
});
server.route({
url: "/:secretImportId",
method: "PATCH",
schema: {
params: z.object({
secretImportId: z.string().trim()
}),
body: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
path: z.string().trim().default("/"),
import: z.object({
environment: z.string().trim().optional(),
path: z.string().trim().optional(),
position: z.number().optional()
})
}),
response: {
200: z.object({
message: z.string(),
secretImport: SecretImportsSchema.omit({ importEnv: true }).merge(
z.object({
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secretImport = await server.services.secretImport.updateImport({
actorId: req.permission.id,
actor: req.permission.type,
id: req.params.secretImportId,
...req.body,
data: req.body.import
});
return { message: "Successfully updated secret import", secretImport };
}
});
server.route({
url: "/:secretImportId",
method: "DELETE",
schema: {
params: z.object({
secretImportId: z.string().trim()
}),
body: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
message: z.string(),
secretImport: SecretImportsSchema.omit({ importEnv: true }).merge(
z.object({
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secretImport = await server.services.secretImport.deleteImport({
actorId: req.permission.id,
actor: req.permission.type,
id: req.params.secretImportId,
...req.body
});
return { message: "Successfully deleted secret import", secretImport };
}
});
server.route({
url: "/",
method: "GET",
schema: {
querystring: z.object({
projectId: z.string().trim(),
environment: z.string().trim(),
path: z.string().trim().default("/")
}),
response: {
200: z.object({
message: z.string(),
secretImports: SecretImportsSchema.omit({ importEnv: true })
.merge(
z.object({
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
})
)
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secretImports = await server.services.secretImport.getImports({
actorId: req.permission.id,
actor: req.permission.type,
...req.query
});
return { message: "Successfully fetched secret imports", secretImports };
}
});
};

View File

@@ -1,7 +1,8 @@
import { z } from "zod";
import { ProjectKeysSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { z } from "zod";
export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({

View File

@@ -1,4 +1,5 @@
import { registerLoginRouter } from "./login-router";
import { registerSecretRouter } from "./secret-router";
import { registerSignupRouter } from "./signup-router";
import { registerUserRouter } from "./user-router";
@@ -6,4 +7,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerSignupRouter, { prefix: "/signup" });
await server.register(registerLoginRouter, { prefix: "/auth" });
await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerSecretRouter, { prefix: "/secrets" });
};

View File

@@ -0,0 +1,372 @@
import { z } from "zod";
import { SecretsSchema,SecretType } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSecretRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
method: "GET",
schema: {
querystring: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
include_imports: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true")
}),
response: {
200: z.object({
secrets: SecretsSchema.omit({ secretBlindIndex: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secrets = await server.services.secret.getSecrets({
actorId: req.permission.id,
actor: req.permission.type,
environment: req.query.environment,
projectId: req.query.workspaceId,
path: req.query.secretPath
});
return { secrets };
}
});
server.route({
url: "/:secretName",
method: "GET",
schema: {
params: z.object({
secretName: z.string().trim()
}),
querystring: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
type: z.nativeEnum(SecretType).default(SecretType.Shared),
include_imports: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true")
}),
response: {
200: z.object({
secret: SecretsSchema.omit({ secretBlindIndex: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secret = await server.services.secret.getASecret({
actorId: req.permission.id,
actor: req.permission.type,
environment: req.query.environment,
projectId: req.query.workspaceId,
path: req.query.secretPath,
secretName: req.params.secretName,
type: req.query.type
});
return { secret };
}
});
server.route({
url: "/:secretName",
method: "POST",
schema: {
body: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
type: z.nativeEnum(SecretType).default(SecretType.Shared),
secretPath: z.string().trim().default("/"),
secretKeyCiphertext: z.string().trim(),
secretKeyIV: z.string().trim(),
secretKeyTag: z.string().trim(),
secretValueCiphertext: z.string().trim(),
secretValueIV: z.string().trim(),
secretValueTag: z.string().trim(),
secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(),
metadata: z.record(z.string()).optional(),
skipMultilineEncoding: z.boolean().optional()
}),
params: z.object({
secretName: z.string().trim()
}),
response: {
200: z.object({
secret: SecretsSchema.omit({ secretBlindIndex: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secret = await server.services.secret.createSecret({
actorId: req.permission.id,
actor: req.permission.type,
path: req.body.secretPath,
type: req.body.type,
environment: req.body.environment,
secretName: req.params.secretName,
projectId: req.body.workspaceId,
secretKeyIV: req.body.secretKeyIV,
secretKeyTag: req.body.secretKeyTag,
secretKeyCiphertext: req.body.secretKeyCiphertext,
secretValueIV: req.body.secretValueIV,
secretValueTag: req.body.secretValueTag,
secretValueCiphertext: req.body.secretValueCiphertext,
secretCommentIV: req.body.secretCommentIV,
secretCommentTag: req.body.secretCommentTag,
secretCommentCiphertext: req.body.secretCommentCiphertext,
skipMultilineEncoding: req.body.skipMultilineEncoding,
metadata: req.body.metadata
});
return { secret };
}
});
server.route({
url: "/:secretName",
method: "PATCH",
schema: {
params: z.object({
secretName: z.string()
}),
body: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretId: z.string().trim().optional(),
type: z.nativeEnum(SecretType).default(SecretType.Shared),
secretPath: z.string().trim().default("/"),
secretValueCiphertext: z.string().trim(),
secretValueIV: z.string().trim(),
secretValueTag: z.string().trim(),
secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(),
secretReminderRepeatDays: z.number().min(1).max(365).optional().nullable(),
secretReminderNote: z.string().trim().nullable().optional(),
tags: z.string().array().optional(),
skipMultilineEncoding: z.boolean().optional(),
// to update secret name
secretName: z.string().trim().optional(),
secretKeyIV: z.string().trim().optional(),
secretKeyTag: z.string().trim().optional(),
secretKeyCiphertext: z.string().trim().optional(),
metadata: z.record(z.string()).optional()
}),
response: {
200: z.object({
secret: SecretsSchema.omit({ secretBlindIndex: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secret = await server.services.secret.updateSecret({
actorId: req.permission.id,
actor: req.permission.type,
path: req.body.secretPath,
type: req.body.type,
environment: req.body.environment,
secretName: req.params.secretName,
projectId: req.body.workspaceId,
secretKeyIV: req.body.secretKeyIV,
secretKeyTag: req.body.secretKeyTag,
secretKeyCiphertext: req.body.secretKeyCiphertext,
secretValueIV: req.body.secretValueIV,
secretValueTag: req.body.secretValueTag,
secretValueCiphertext: req.body.secretValueCiphertext,
secretCommentIV: req.body.secretCommentIV,
secretCommentTag: req.body.secretCommentTag,
secretCommentCiphertext: req.body.secretCommentCiphertext,
skipMultilineEncoding: req.body.skipMultilineEncoding,
metadata: req.body.metadata,
secretReminderRepeatDays: req.body.secretReminderRepeatDays,
secretReminderNote: req.body.secretReminderNote,
newSecretName: req.body.secretName
});
return { secret };
}
});
server.route({
url: "/:secretName",
method: "DELETE",
schema: {
params: z.object({
secretName: z.string()
}),
body: z.object({
type: z.nativeEnum(SecretType).default(SecretType.Shared),
secretPath: z.string().trim().default("/"),
secretId: z.string().trim().optional(),
workspaceId: z.string().trim(),
environment: z.string().trim()
}),
response: {
200: z.object({
secret: SecretsSchema.omit({ secretBlindIndex: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secret = await server.services.secret.deleteSecret({
actorId: req.permission.id,
actor: req.permission.type,
path: req.body.secretPath,
type: req.body.type,
environment: req.body.environment,
secretName: req.params.secretName,
projectId: req.body.workspaceId,
secretId: req.body.secretId
});
return { secret };
}
});
server.route({
url: "/batch",
method: "POST",
schema: {
body: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
secrets: z
.object({
secretName: z.string().trim(),
type: z.nativeEnum(SecretType).default(SecretType.Shared),
secretKeyCiphertext: z.string().trim(),
secretKeyIV: z.string().trim(),
secretKeyTag: z.string().trim(),
secretValueCiphertext: z.string().trim(),
secretValueIV: z.string().trim(),
secretValueTag: z.string().trim(),
secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(),
metadata: z.record(z.string()).optional(),
skipMultilineEncoding: z.boolean().optional()
})
.array()
.min(1)
}),
response: {
200: z.object({
secrets: SecretsSchema.omit({ secretBlindIndex: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secrets = await server.services.secret.createManySecret({
actorId: req.permission.id,
actor: req.permission.type,
path: req.body.secretPath,
environment: req.body.environment,
projectId: req.body.workspaceId,
secrets: req.body.secrets
});
return { secrets };
}
});
server.route({
url: "/batch",
method: "PATCH",
schema: {
body: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
secrets: z
.object({
secretName: z.string().trim(),
type: z.nativeEnum(SecretType).default(SecretType.Shared),
secretValueCiphertext: z.string().trim(),
secretValueIV: z.string().trim(),
secretValueTag: z.string().trim(),
secretKeyCiphertext: z.string().trim(),
secretKeyIV: z.string().trim(),
secretKeyTag: z.string().trim(),
secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(),
skipMultilineEncoding: z.boolean().optional(),
tags: z.string().array().optional()
})
.array()
.min(1)
}),
response: {
200: z.object({
secrets: SecretsSchema.omit({ secretBlindIndex: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secrets = await server.services.secret.updateManySecret({
actorId: req.permission.id,
actor: req.permission.type,
path: req.body.secretPath,
environment: req.body.environment,
projectId: req.body.workspaceId,
secrets: req.body.secrets
});
return { secrets };
}
});
server.route({
url: "/batch",
method: "DELETE",
schema: {
body: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
secrets: z
.object({
secretName: z.string().trim(),
type: z.nativeEnum(SecretType).default(SecretType.Shared)
})
.array()
.min(1)
}),
response: {
200: z.object({
secrets: SecretsSchema.omit({ secretBlindIndex: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => {
const secrets = await server.services.secret.deleteManySecret({
actorId: req.permission.id,
actor: req.permission.type,
path: req.body.secretPath,
environment: req.body.environment,
projectId: req.body.workspaceId,
secrets: req.body.secrets
});
return { secrets };
}
});
};

View File

@@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
@@ -14,7 +15,6 @@ import {
TResetPasswordViaBackupKeyDTO
} from "./auth-password-type";
import { AuthTokenType } from "./auth-type";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
type TAuthPasswordServiceFactoryDep = {
authDal: TAuthDalFactory;

View File

@@ -1,10 +1,63 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TProjectEnvDalFactory = ReturnType<typeof projectEnvDalFactory>;
export const projectEnvDalFactory = (db: TDbClient) => {
const projectEnvOrm = ormify(db, TableName.Environment);
return projectEnvOrm;
const findBySlugs = async (projectId: string, env: string[], tx?: Knex) => {
try {
const envs = await (tx || db)(TableName.Environment)
.where("projectId", projectId)
.whereIn("slug", env);
return envs;
} catch (error) {
throw new DatabaseError({ error, name: "Find by slugs" });
}
};
// we are using postion based sorting as its a small list
// this will return the last value of the position in a folder with secret imports
const findLastEnvPosition = async (projectId: string, tx?: Knex) => {
const lastPos = await (tx || db)(TableName.Environment)
.where({ projectId })
.max("position")
.first();
return lastPos?.position || 1;
};
const incrementLastPosition = async (
projectId: string,
startPos: number,
increment = 1,
tx?: Knex
) =>
(tx || db)(TableName.Environment)
.where("projectId", projectId)
.where("postion", ">=", startPos)
.increment("position", increment);
const decrementLastPosition = async (
projectId: string,
startPos: number,
decrement = 1,
tx?: Knex
) =>
(tx || db)(TableName.Environment)
.where("projectId", projectId)
.where("postion", ">", startPos)
.decrement("position", decrement);
return {
...projectEnvOrm,
findBySlugs,
findLastEnvPosition,
decrementLastPosition,
incrementLastPosition
};
};

View File

@@ -8,7 +8,7 @@ import {
import { BadRequestError } from "@app/lib/errors";
import { TProjectEnvDalFactory } from "./project-env-dal";
import { TCreateEnvDTO, TDeleteEnvDTO, TUpdateEnvDTO } from "./project-env-types";
import { TCreateEnvDTO, TDeleteEnvDTO, TReorderEnvDTO, TUpdateEnvDTO } from "./project-env-types";
type TProjectEnvServiceFactoryDep = {
projectEnvDal: TProjectEnvDalFactory;
@@ -36,7 +36,11 @@ export const projectEnvServiceFactory = ({
name: "Create envv"
});
const env = await projectEnvDal.create({ slug, name, projectId });
const env = await projectEnvDal.transaction(async (tx) => {
const lastPos = await projectEnvDal.findLastEnvPosition(projectId, tx);
const doc = await projectEnvDal.create({ slug, name, projectId, position: lastPos }, tx);
return doc;
});
return env;
};
@@ -75,13 +79,44 @@ export const projectEnvServiceFactory = ({
ProjectPermissionSub.Environments
);
const [env] = await projectEnvDal.delete({ id, projectId });
const env = await projectEnvDal.transaction(async (tx) => {
const [doc] = await projectEnvDal.delete({ id, projectId }, tx);
if (!doc)
throw new BadRequestError({
message: "Env doesn't exist",
name: "Re-order env"
});
await projectEnvDal.decrementLastPosition(projectId, doc.position, 1, tx);
return doc;
});
return env;
};
const reorderEnvironment = async ({ projectId, id, actorId, actor, pos }: TReorderEnvDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Environments
);
const [env] = await projectEnvDal.transaction(async (tx) => {
await projectEnvDal.incrementLastPosition(projectId, pos, 1, tx);
return projectEnvDal.update({ id, projectId }, { position: pos }, tx);
});
if (!env)
throw new BadRequestError({
message: "Env doesn't exist",
name: "Re-order env"
});
return env;
};
return {
createEnvironment,
updateEnvironment,
deleteEnvironment
deleteEnvironment,
reorderEnvironment
};
};

View File

@@ -14,3 +14,8 @@ export type TUpdateEnvDTO = {
export type TDeleteEnvDTO = {
id: string;
} & TProjectPermission;
export type TReorderEnvDTO = {
id: string;
pos: number;
} & TProjectPermission;

View File

@@ -10,9 +10,13 @@ import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { createSecretBlindIndex } from "@app/lib/crypto";
import { TProjectEnvDalFactory } from "../project-env/project-env-dal";
import { TProjectMembershipDalFactory } from "../project-membership/project-membership-dal";
import { TSecretBlindIndexDalFactory } from "../secret/secret-blind-index-dal";
import { ROOT_FOLDER_NAME, TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal";
import { TProjectDalFactory } from "./project-dal";
import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types";
@@ -24,8 +28,10 @@ const DEFAULT_PROJECT_ENVS = [
type TProjectServiceFactoryDep = {
projectDal: TProjectDalFactory;
folderDal: Pick<TSecretFolderDalFactory, "insertMany">;
projectEnvDal: Pick<TProjectEnvDalFactory, "insertMany">;
projectMembershipDal: Pick<TProjectMembershipDalFactory, "create">;
secretBlindIndexDal: Pick<TSecretBlindIndexDalFactory, "create">;
permissionService: TPermissionServiceFactory;
};
@@ -34,6 +40,8 @@ export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
export const projectServiceFactory = ({
projectDal,
permissionService,
folderDal,
secretBlindIndexDal,
projectMembershipDal,
projectEnvDal
}: TProjectServiceFactoryDep) => {
@@ -47,9 +55,12 @@ export const projectServiceFactory = ({
OrgPermissionSubjects.Workspace
);
const appCfg = getConfig();
const blindIndex = createSecretBlindIndex(appCfg.ROOT_ENCRYPTION_KEY, appCfg.ENCRYPTION_KEY);
// TODO(backend-pg): licence server
const newProject = projectDal.transaction(async (tx) => {
const project = await projectDal.create({ name: workspaceName, orgId }, tx);
// set user as admin member for proeject
await projectMembershipDal.create(
{
userId: actorId,
@@ -58,8 +69,25 @@ export const projectServiceFactory = ({
},
tx
);
// generate the blind index for project
await secretBlindIndexDal.create(
{
projectId: project.id,
keyEncoding: blindIndex.keyEncoding,
saltIV: blindIndex.iv,
saltTag: blindIndex.tag,
algorithm: blindIndex.algorithm,
encryptedSaltCipherText: blindIndex.ciphertext
},
tx
);
// set default environments and root folder for provided environments
const envs = await projectEnvDal.insertMany(
DEFAULT_PROJECT_ENVS.map((el) => ({ ...el, projectId: project.id })),
DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })),
tx
);
await folderDal.insertMany(
envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })),
tx
);
return { ...project, environments: envs };

View File

@@ -0,0 +1,40 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TSecretFolders } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TSecretFolderDalFactory = ReturnType<typeof secretFolderDalFactory>;
// never change this. If u do write a migration for it
export const ROOT_FOLDER_NAME = "root";
export const secretFolderDalFactory = (db: TDbClient) => {
const secretFolderOrm = ormify(db, TableName.SecretFolder);
const findBySecretPath = async (
projectId: string,
environment: string,
path: string,
tx?: Knex
) => {
try {
const folder: TSecretFolders | undefined = await (tx || db)(TableName.SecretFolder)
.join(
TableName.Environment,
`${TableName.SecretFolder}.envId`,
`${TableName.Environment}.id`
)
.join(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`)
.where(`${TableName.Project}.id`, projectId)
.where(`${TableName.Environment}.slug`, environment)
.where(`${TableName.SecretFolder}.name`, "root")
.select(`${TableName.SecretFolder}.*`)
.first();
return folder;
} catch (error) {
throw new DatabaseError({ error, name: "Find by secret path" });
}
};
return { ...secretFolderOrm, findBySecretPath };
};

View File

@@ -0,0 +1,132 @@
import { ForbiddenError, subject } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { TProjectEnvDalFactory } from "../project-env/project-env-dal";
import { ROOT_FOLDER_NAME, TSecretFolderDalFactory } from "./secret-folder-dal";
import {
TCreateFolderDTO,
TDeleteFolderDTO,
TGetFolderDTO,
TUpdateFolderDTO
} from "./secret-folder-types";
type TSecretFolderServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
folderDal: TSecretFolderDalFactory;
projectEnvDal: Pick<TProjectEnvDalFactory, "findOne">;
};
export type TSecretFolderServiceFactory = ReturnType<typeof secretFolderServiceFactory>;
export const secretFolderServiceFactory = ({
folderDal,
permissionService,
projectEnvDal
}: TSecretFolderServiceFactoryDep) => {
const createFolder = async ({
projectId,
actor,
actorId,
name,
environment,
path
}: TCreateFolderDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const env = await projectEnvDal.findOne({ projectId, slug: environment });
if (!env)
throw new BadRequestError({ message: "Environment not found", name: "Create folder" });
const folder = await folderDal.transaction(async (tx) => {
const doc = await folderDal.create({ name, envId: env.id, version: 1 }, tx);
return doc;
});
return folder;
};
const updateFolder = async ({
projectId,
actor,
actorId,
name,
environment,
path,
id
}: TUpdateFolderDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const env = await projectEnvDal.findOne({ projectId, slug: environment });
if (!env)
throw new BadRequestError({ message: "Environment not found", name: "Create folder" });
const folder = await folderDal.transaction(async (tx) => {
const [doc] = await folderDal.update({ envId: env.id, id }, { name, version: 1 }, tx);
if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Update folder" });
return doc;
});
return folder;
};
const deleteFolder = async ({
projectId,
actor,
actorId,
environment,
path,
id
}: TDeleteFolderDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const env = await projectEnvDal.findOne({ projectId, slug: environment });
if (!env)
throw new BadRequestError({ message: "Environment not found", name: "Create folder" });
const folder = await folderDal.transaction(async (tx) => {
const [doc] = await folderDal.delete({ envId: env.id, id }, tx);
if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" });
return doc;
});
return folder;
};
const getFolders = async ({ projectId, actor, actorId, environment }: TGetFolderDTO) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
await permissionService.getProjectPermission(actor, actorId, projectId);
const env = await projectEnvDal.findOne({ projectId, slug: environment });
if (!env)
throw new BadRequestError({ message: "Environment not found", name: "Create folder" });
const folders = await folderDal.find({ envId: env.id, parentId: null });
return folders.filter(({ name }) => name !== ROOT_FOLDER_NAME);
};
return {
createFolder,
updateFolder,
deleteFolder,
getFolders
};
};

View File

@@ -0,0 +1,25 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateFolderDTO = {
environment: string;
path: string;
name: string;
} & TProjectPermission;
export type TUpdateFolderDTO = {
environment: string;
path: string;
id: string;
name: string;
} & TProjectPermission;
export type TDeleteFolderDTO = {
environment: string;
path: string;
id: string;
} & TProjectPermission;
export type TGetFolderDTO = {
environment: string;
path: string;
} & TProjectPermission;

View File

@@ -0,0 +1,83 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TSecretImports } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TSecretImportDalFactory = ReturnType<typeof secretImportDalFactory>;
export const secretImportDalFactory = (db: TDbClient) => {
const secretImportOrm = ormify(db, TableName.SecretImport);
// we are using postion based sorting as its a small list
// this will return the last value of the position in a folder with secret imports
const findLastImportPosition = async (folderId: string, tx?: Knex) => {
const lastPos = await (tx || db)(TableName.SecretImport)
.where({ folderId })
.max({ position: "position" })
.first();
return lastPos?.position || 0;
};
const updateAllPosition = async (folderId: string, pos: number, targetPos: number, tx?: Knex) => {
try {
if (targetPos === -1) {
// this means delete
await (tx || db)(TableName.SecretImport)
.where({ folderId })
.andWhere("position", ">", pos)
.decrement("position", 1);
return;
}
if (targetPos > pos) {
await (tx || db)(TableName.SecretImport)
.where({ folderId })
.where("position", "<=", targetPos)
.andWhere("position", ">", pos)
.decrement("position", 1);
} else {
await (tx || db)(TableName.SecretImport)
.where({ folderId })
.where("position", ">=", targetPos)
.andWhere("position", "<", pos)
.increment("position", 1);
}
} catch (error) {
throw new DatabaseError({ error, name: "Update position" });
}
};
const find = async (filter: Partial<TSecretImports>, tx?: Knex) => {
try {
const docs = await (tx || db)(TableName.SecretImport)
.where(filter)
.join(
TableName.Environment,
`${TableName.SecretImport}.importEnv`,
`${TableName.Environment}.id`
)
.select(
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
db.ref("slug").withSchema(TableName.Environment),
db.ref("name").withSchema(TableName.Environment),
db.ref("id").withSchema(TableName.Environment).as("envId")
)
.orderBy("position", "asc");
return docs.map(({ envId, slug, name, ...el }) => ({
...el,
importEnv: { id: envId, slug, name }
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find secret imports" });
}
};
return {
...secretImportOrm,
find,
findLastImportPosition,
updateAllPosition
};
};

View File

@@ -0,0 +1,177 @@
import { ForbiddenError, subject } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { TProjectEnvDalFactory } from "../project-env/project-env-dal";
import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal";
import { TSecretImportDalFactory } from "./secret-import-dal";
import {
TCreateSecretImportDTO,
TDeleteSecretImportDTO,
TGetSecretImportsDTO,
TUpdateSecretImportDTO
} from "./secret-import-types";
type TSecretImportServiceFactoryDep = {
secretImportDal: TSecretImportDalFactory;
folderDal: TSecretFolderDalFactory;
projectEnvDal: TProjectEnvDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" });
export type TSecretImportServiceFactory = ReturnType<typeof secretImportServiceFactory>;
export const secretImportServiceFactory = ({
secretImportDal,
projectEnvDal,
permissionService,
folderDal
}: TSecretImportServiceFactoryDep) => {
const createImport = async ({
environment,
data,
actor,
actorId,
projectId,
path
}: TCreateSecretImportDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" });
const [importEnv] = await projectEnvDal.findBySlugs(projectId, [data.environment]);
if (!importEnv)
throw new BadRequestError({ error: "Imported env not found", name: "Create import" });
const secImport = await secretImportDal.transaction(async (tx) => {
const lastPos = await secretImportDal.findLastImportPosition(folder.id, tx);
return secretImportDal.create(
{
folderId: folder.id,
position: lastPos + 1,
importEnv: importEnv.id,
importPath: data.path
},
tx
);
});
return { ...secImport, importEnv };
};
const updateImport = async ({
path,
environment,
projectId,
actor,
actorId,
data,
id
}: TUpdateSecretImportDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" });
const secImpDoc = await secretImportDal.findOne({ folderId: folder.id, id });
if (!secImpDoc) throw ERR_SEC_IMP_NOT_FOUND;
const importedEnv = data.environment // this is get env information of new one or old one
? (await projectEnvDal.findBySlugs(projectId, [data.environment]))?.[0]
: await projectEnvDal.findById(secImpDoc.importEnv);
if (!importedEnv)
throw new BadRequestError({ error: "Imported env not found", name: "Create import" });
const updatedSecImport = await secretImportDal.transaction(async (tx) => {
const secImp = await secretImportDal.findOne({ folderId: folder.id, id });
if (!secImp) throw ERR_SEC_IMP_NOT_FOUND;
if (data.position) {
await secretImportDal.updateAllPosition(folder.id, secImp.position, data.position, tx);
}
const [doc] = await secretImportDal.update(
{ id, folderId: folder.id },
{
position: data?.position,
importEnv: data?.environment ? importedEnv.id : undefined,
importPath: data?.path
},
tx
);
return doc;
});
return { ...updatedSecImport, importEnv: importedEnv };
};
const deleteImport = async ({
path,
environment,
projectId,
actor,
actorId,
id
}: TDeleteSecretImportDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" });
const secImport = await secretImportDal.transaction(async (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" });
await secretImportDal.updateAllPosition(folder.id, doc.position, -1, tx);
const importEnv = await projectEnvDal.findById(doc.importEnv);
if (!importEnv)
throw new BadRequestError({ error: "Imported env not found", name: "Create import" });
return { ...doc, importEnv };
});
return secImport;
};
const getImports = async ({
path,
environment,
projectId,
actor,
actorId
}: TGetSecretImportsDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
const secImports = await secretImportDal.find({ folderId: folder.id });
return secImports;
};
return {
createImport,
updateImport,
deleteImport,
getImports
};
};

View File

@@ -0,0 +1,28 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateSecretImportDTO = {
environment: string;
path: string;
data: {
environment: string;
path: string;
};
} & TProjectPermission;
export type TUpdateSecretImportDTO = {
environment: string;
path: string;
id: string;
data: Partial<{ environment: string; path: string; position: number }>;
} & TProjectPermission;
export type TDeleteSecretImportDTO = {
environment: string;
path: string;
id: string;
} & TProjectPermission;
export type TGetSecretImportsDTO = {
environment: string;
path: string;
} & TProjectPermission;

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 TSecretBlindIndexDalFactory = ReturnType<typeof secretBlindIndexDalFactory>;
export const secretBlindIndexDalFactory = (db: TDbClient) => {
const secretBlindIndexOrm = ormify(db, TableName.SecretBlindIndex);
return secretBlindIndexOrm;
};

View File

@@ -0,0 +1,110 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { SecretType, TableName, TSecrets, TSecretsInsert,TSecretsUpdate } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TSecretDalFactory = ReturnType<typeof secretDalFactory>;
export const secretDalFactory = (db: TDbClient) => {
const secretOrm = ormify(db, TableName.Secret);
const update = async (
filter: Partial<TSecrets>,
data: Omit<TSecretsUpdate, "version">,
tx?: Knex
) => {
try {
const sec = await (tx || db)(TableName.Secret)
.where(filter)
.update(data)
.increment("version", 1)
.returning("*");
return sec;
} catch (error) {
throw new DatabaseError({ error, name: "update secret" });
}
};
// the idea is to use postgres specific function
// insert with id this will cause a conflict then merge the data
const bulkUpdate = async (data: Array<TSecretsUpdate & { id: string }>, tx?: Knex) => {
try {
const secs = await (tx || db)(TableName.Secret)
.insert(data as TSecretsInsert[])
.onConflict("id")
.merge()
.returning("*");
return secs;
} catch (error) {
throw new DatabaseError({ error, name: "bulk update secret" });
}
};
const deleteMany = async (
data: Array<{ blindIndex: string; type: SecretType }>,
folderId: string,
userId: string,
tx?: Knex
) => {
try {
const deletedSecrets = await (tx || db)(TableName.Secret)
.where({ folderId })
.where((bd) => {
data.forEach((el) => {
bd.orWhere({
secretBlindIndex: el.blindIndex,
type: el.type,
userId: el.type === SecretType.Personal ? userId : null
});
});
})
.delete()
.returning("*");
return deletedSecrets;
} catch (error) {
throw new DatabaseError({ error, name: "delete many secret" });
}
};
const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => {
try {
const sec = await (tx || db)(TableName.Secret)
.where({ folderId })
.where((bd) => {
bd.whereNull("userId").orWhere({ userId: userId || null });
});
return sec;
} catch (error) {
throw new DatabaseError({ error, name: "get all secret" });
}
};
const findByBlindIndexes = async (
folderId: string,
blindIndexes: Array<{ blindIndex: string; type: SecretType }>,
userId?: string,
tx?: Knex
) => {
if (!blindIndexes.length) return [];
try {
const secrets = await (tx || db)(TableName.Secret)
.where({ folderId })
.where((bd) => {
blindIndexes.forEach((el) => {
bd.orWhere({
secretBlindIndex: el.blindIndex,
type: el.type,
userId: el.type === SecretType.Personal ? userId : null
});
});
});
return secrets;
} catch (error) {
throw new DatabaseError({ error, name: "find by blind indexes" });
}
};
return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes };
};

View File

@@ -0,0 +1,589 @@
import { ForbiddenError, subject } from "@casl/ability";
import {
SecretEncryptionAlgo,
SecretKeyEncoding,
SecretType,
TSecretBlindIndexes,
TSecrets
} from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { buildSecretBlindIndexFromName } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal";
import { TSecretBlindIndexDalFactory } from "./secret-blind-index-dal";
import { TSecretDalFactory } from "./secret-dal";
import {
TCreateBulkSecretDTO,
TCreateSecretDTO,
TDeleteBulkSecretDTO,
TDeleteSecretDTO,
TGetASecretDTO,
TGetSecretsDTO,
TUpdateBulkSecretDTO,
TUpdateSecretDTO
} from "./secret-types";
import { TSecretVersionDalFactory } from "./secret-version-dal";
type TSecretServiceFactoryDep = {
secretDal: TSecretDalFactory;
secretVersionDal: TSecretVersionDalFactory;
folderDal: TSecretFolderDalFactory;
secretBlindIndexDal: TSecretBlindIndexDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
export const secretServiceFactory = ({
secretDal,
secretVersionDal,
folderDal,
secretBlindIndexDal,
permissionService
}: TSecretServiceFactoryDep) => {
const generateSecretBlindIndexBySalt = async (
secretName: string,
secretBlindIndexDoc: TSecretBlindIndexes
) => {
const appCfg = getConfig();
const secretBlindIndex = await buildSecretBlindIndexFromName({
secretName,
keyEncoding: secretBlindIndexDoc.keyEncoding as SecretKeyEncoding,
rootEncryptionKey: appCfg.ROOT_ENCRYPTION_KEY,
encryptionKey: appCfg.ENCRYPTION_KEY,
tag: secretBlindIndexDoc.saltTag,
ciphertext: secretBlindIndexDoc.encryptedSaltCipherText,
iv: secretBlindIndexDoc.saltIV
});
return secretBlindIndex;
};
// utility function to get secret blind index data
const generateSecretBlindIndexByName = async (projectId: string, secretName: string) => {
const appCfg = getConfig();
const secretBlindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!secretBlindIndexDoc)
throw new BadRequestError({ message: "Blind index not found", name: "Create secret" });
const secretBlindIndex = await buildSecretBlindIndexFromName({
secretName,
keyEncoding: secretBlindIndexDoc.keyEncoding as SecretKeyEncoding,
rootEncryptionKey: appCfg.ROOT_ENCRYPTION_KEY,
encryptionKey: appCfg.ENCRYPTION_KEY,
tag: secretBlindIndexDoc.saltTag,
ciphertext: secretBlindIndexDoc.encryptedSaltCipherText,
iv: secretBlindIndexDoc.saltIV
});
if (!secretBlindIndex) throw new BadRequestError({ message: "Secret not found" });
return secretBlindIndex;
};
const createSecret = async ({
path,
actor,
actorId,
environment,
projectId,
...inputSecret
}: TCreateSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const secretBlindIndex = await generateSecretBlindIndexByName(
projectId,
inputSecret.secretName
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const existingSecret = await secretDal.findOne({
secretBlindIndex,
folderId,
type: inputSecret.type,
userId: inputSecret.type === SecretType.Personal ? actorId : null
});
if (existingSecret) throw new BadRequestError({ message: "Secret already exist" });
// if user creating personal check its shared also exist
if (inputSecret.type === SecretType.Personal) {
const sharedExist = await secretDal.findOne({
secretBlindIndex,
folderId,
type: SecretType.Shared
});
if (!sharedExist)
throw new BadRequestError({
message: "Failed to create personal secret override for no corresponding shared secret"
});
}
const secret = await secretDal.transaction(async (tx) => {
const { secretName, type, ...el } = inputSecret;
const doc = await secretDal.create(
{
version: 1,
folderId,
secretBlindIndex,
type,
...el,
userId: inputSecret.type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
},
tx
);
await secretVersionDal.create(
{
secretBlindIndex,
folderId,
version: 1,
type,
...el,
userId: inputSecret.type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8,
secretId: doc.id
},
tx
);
return doc;
});
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return secret;
};
const updateSecret = async ({
path,
actor,
actorId,
environment,
projectId,
...inputSecret
}: TUpdateSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
const oldBlindIndex = await generateSecretBlindIndexBySalt(
inputSecret.secretName,
blindIndexDoc
);
if (!oldBlindIndex) throw new BadRequestError({ message: "Secret not found" });
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
let newSecretNameBlindIndex: string;
if (inputSecret?.newSecretName && inputSecret.type === SecretType.Shared) {
newSecretNameBlindIndex = await generateSecretBlindIndexBySalt(
inputSecret.newSecretName,
blindIndexDoc
);
const doesSecretExist = await secretDal.findOne({
secretBlindIndex: newSecretNameBlindIndex,
folderId,
type: inputSecret.type
});
if (doesSecretExist) {
throw new BadRequestError({ message: "Secret with the provided name already exist" });
}
}
const updatedSecret = await secretDal.transaction(async (tx) => {
const { secretName, ...el } = inputSecret;
const [doc] = await secretDal.update(
{
secretBlindIndex: oldBlindIndex,
folderId,
type: inputSecret.type,
userId: inputSecret.type === SecretType.Personal ? actorId : null
},
{
secretBlindIndex: newSecretNameBlindIndex,
...el
},
tx
);
const { id, createdAt, updatedAt, ...newVersion } = doc;
await secretVersionDal.create(
{
userId: inputSecret.type === SecretType.Personal ? actorId : null,
secretId: doc.id,
...newVersion
},
tx
);
return doc;
});
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return updatedSecret;
};
const deleteSecret = async ({
path,
actor,
actorId,
environment,
projectId,
...inputSecret
}: TDeleteSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const secretBlindIndex = await generateSecretBlindIndexByName(
projectId,
inputSecret.secretName
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const deletedSecret = await secretDal.transaction(async (tx) => {
const [doc] = await secretDal.delete(
{
secretBlindIndex,
folderId,
type: inputSecret.type,
userId: inputSecret.type === SecretType.Personal ? actorId : null
},
tx
);
return doc;
});
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return deletedSecret;
};
const getSecrets = async ({ actorId, path, environment, projectId, actor }: TGetSecretsDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const secrets = await secretDal.findByFolderId(folderId, actorId);
return secrets;
};
const getASecret = async ({
actorId,
actor,
projectId,
environment,
path,
type,
secretName
}: TGetASecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const secretBlindIndex = await generateSecretBlindIndexByName(projectId, secretName);
const secret = await secretDal.findOne({
folderId,
type,
userId: type === SecretType.Personal ? actorId : null,
secretBlindIndex
});
if (!secret) throw new BadRequestError({ message: "Secret not found" });
return secret;
};
const createManySecret = async ({
path,
actor,
actorId,
environment,
projectId,
secrets: inputSecrets
}: TCreateBulkSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
const secretBlindIndexes = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
secretBlindIndexToKey[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
const exists = await secretDal.findByBlindIndexes(
folderId,
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
}))
);
if (exists.length) throw new BadRequestError({ message: "Secret already exist" });
const secrets = await secretDal.transaction(async (tx) => {
const newSecrets = await secretDal.insertMany(
inputSecrets.map(({ secretName, type, ...el }) => ({
version: 1,
folderId,
type,
secretBlindIndex: secretBlindIndexes[secretName],
...el,
userId: type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
})),
tx
);
await secretVersionDal.insertMany(
newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
})),
tx
);
return newSecrets;
});
return secrets;
};
const updateManySecret = async ({
path,
actor,
actorId,
environment,
projectId,
secrets: inputSecrets
}: TUpdateBulkSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
// get all blind index
// Find all those secrets
// if not throw not found
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
const secretBlindIndexes = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
secretBlindIndexToKey[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
const secretsToBeUpdated = await secretDal.findByBlindIndexes(
folderId,
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
}))
);
if (secretsToBeUpdated.length !== inputSecrets.length)
throw new BadRequestError({ message: "Secret not found" });
// now find any secret that needs to update its name
// same process as above
const nameUpdatedSecrets = inputSecrets.filter(({ newSecretName }) => Boolean(newSecretName));
const newSecretBlindIndexes = await Promise.all(
nameUpdatedSecrets.map(({ newSecretName }) =>
generateSecretBlindIndexBySalt(newSecretName as string, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[nameUpdatedSecrets[i].secretName] = curr;
return prev;
}, {})
);
const secretsWithNewName = await secretDal.findByBlindIndexes(
folderId,
nameUpdatedSecrets.map(({ type, newSecretName }) => ({
blindIndex: newSecretBlindIndexes[newSecretName as string],
type
}))
);
if (secretsWithNewName.length) throw new BadRequestError({ message: "Secret not found" });
const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce<Record<string, TSecrets>>(
(prev, curr) => {
// eslint-disable-next-line
if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr;
return prev;
},
{}
);
const secrets = await secretDal.transaction(async (tx) => {
const newSecrets = await secretDal.bulkUpdate(
inputSecrets.map(({ secretName, type, ...el }) => {
const { version, updatedAt, ...info } =
secretsGroupedByBlindIndex[secretBlindIndexes[secretName]];
return {
version: (version || 0) + 1,
...info,
folderId,
type,
secretBlindIndex:
el?.newSecretName && newSecretBlindIndexes[el.newSecretName]
? newSecretBlindIndexes[el.newSecretName]
: secretBlindIndexes[secretName],
...el,
userId: type === SecretType.Personal ? actorId : null,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
};
}),
tx
);
await secretVersionDal.insertMany(
newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
})),
tx
);
return newSecrets;
});
return secrets;
};
const deleteManySecret = async ({
secrets: inputSecrets,
path,
environment,
projectId,
actor,
actorId
}: TDeleteBulkSecretDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDal.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
if (!blindIndexDoc)
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
// get all blind index
// Find all those secrets
// if not throw not found
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
const secretBlindIndexes = await Promise.all(
inputSecrets.map(({ secretName }) =>
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
)
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
secretBlindIndexToKey[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
// not find those secrets. if any of them not found throw an not found error
const secretsToBeDeleted = await secretDal.findByBlindIndexes(
folderId,
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
}))
);
if (secretsToBeDeleted.length !== inputSecrets.length)
throw new BadRequestError({ message: "Secret not found" });
const secretsDeleted = await secretDal.transaction(async (tx) =>
secretDal.deleteMany(
inputSecrets.map(({ type, secretName }) => ({
blindIndex: secretBlindIndexes[secretName],
type
})),
folderId,
actorId,
tx
)
);
return secretsDeleted;
};
return {
createSecret,
deleteSecret,
updateSecret,
createManySecret,
updateManySecret,
deleteManySecret,
getASecret,
getSecrets
};
};

View File

@@ -0,0 +1,115 @@
import { SecretType } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
export type TCreateSecretDTO = {
secretName: string;
path: string;
environment: string;
type: "shared" | "personal";
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;
skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
metadata?: {
source?: string;
};
} & TProjectPermission;
export type TUpdateSecretDTO = {
secretName: string;
path: string;
newSecretName?: string;
environment: string;
type: "shared" | "personal";
secretKeyCiphertext?: string;
secretKeyIV?: string;
secretKeyTag?: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;
skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
metadata?: {
source?: string;
};
} & TProjectPermission;
export type TDeleteSecretDTO = {
secretName: string;
secretId?: string;
path: string;
environment: string;
type: "shared" | "personal";
} & TProjectPermission;
export type TGetSecretsDTO = {
path: string;
environment: string;
} & TProjectPermission;
export type TGetASecretDTO = {
secretName: string;
path: string;
environment: string;
type: "shared" | "personal";
} & TProjectPermission;
export type TCreateBulkSecretDTO = {
path: string;
environment: string;
secrets: Array<{
secretName: string;
type: SecretType;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;
skipMultilineEncoding?: boolean;
metadata?: {
source?: string;
};
}>;
} & TProjectPermission;
export type TUpdateBulkSecretDTO = {
path: string;
environment: string;
secrets: Array<{
type: SecretType;
secretName: string;
newSecretName?: string;
secretValueCiphertext?: string;
secretValueIV?: string;
secretValueTag?: string;
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;
skipMultilineEncoding?: boolean;
}>;
} & TProjectPermission;
export type TDeleteBulkSecretDTO = {
path: string;
environment: string;
secrets: Array<{
type: SecretType;
secretName: string;
}>;
} & TProjectPermission;

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 TSecretVersionDalFactory = ReturnType<typeof secretVersionDalFactory>;
export const secretVersionDalFactory = (db: TDbClient) => {
const secretVersionOrm = ormify(db, TableName.SecretVersion);
return secretVersionOrm;
};

View File

@@ -14,6 +14,7 @@ import {
TGetUserProjectPermissionDTO,
TOrgRole,
TPermission,
TProjectPermission,
TProjectRole
} from "./types";
@@ -50,7 +51,7 @@ const getProjectRoles = async (projectId: string) => {
}>(`/api/ee/v1/workspace/${projectId}/roles`);
return data.data.roles.map(({ permissions, ...el }) => ({
...el,
permissions: unpackRules(permissions as PackRule<TPermission>[])
permissions: unpackRules(permissions as PackRule<TProjectPermission>[])
}));
};

View File

@@ -10,7 +10,7 @@ export type TProjectRole = {
createdAt: string;
updatedAt: string;
description?: string;
permissions: TPermission[];
permissions: TProjectPermission[];
};
export type TOrgRole = {
@@ -67,13 +67,13 @@ export type TCreateProjectRoleDTO = {
name: string;
description?: string;
slug: string;
permissions: TPermission[];
permissions: TProjectPermission[];
};
export type TUpdateProjectRoleDTO = {
projectId: string;
id: string;
} & Partial<Omit<TCreateOrgRoleDTO, "orgId">>;
} & Partial<Omit<TCreateProjectRoleDTO, "orgId">>;
export type TDeleteProjectRoleDTO = {
projectId: string;

View File

@@ -20,25 +20,25 @@ import {
} from "./types";
const queryKeys = {
getSecretFolders: ({ workspaceId, environment, directory }: TGetProjectFoldersDTO) =>
["secret-folders", { workspaceId, environment, directory }] as const
getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) =>
["secret-folders", { projectId, environment, path }] as const
};
const fetchProjectFolders = async (workspaceId: string, environment: string, directory = "/") => {
const fetchProjectFolders = async (projectId: string, environment: string, path = "/") => {
const { data } = await apiRequest.get<{ folders: TSecretFolder[] }>("/api/v1/folders", {
params: {
workspaceId,
projectId,
environment,
directory
path
}
});
return data.folders;
};
export const useGetProjectFolders = ({
workspaceId,
projectId,
environment,
directory = "/",
path = "/",
options = {}
}: TGetProjectFoldersDTO & {
options?: Omit<
@@ -53,21 +53,21 @@ export const useGetProjectFolders = ({
}) =>
useQuery({
...options,
queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }),
enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true),
queryFn: async () => fetchProjectFolders(workspaceId, environment, directory)
queryKey: queryKeys.getSecretFolders({ projectId, environment, path }),
enabled: Boolean(projectId) && Boolean(environment) && (options?.enabled ?? true),
queryFn: async () => fetchProjectFolders(projectId, environment, path)
});
export const useGetFoldersByEnv = ({
directory = "/",
workspaceId,
path = "/",
projectId,
environments
}: TGetFoldersByEnvDTO) => {
const folders = useQueries({
queries: environments.map((environment) => ({
queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }),
queryFn: async () => fetchProjectFolders(workspaceId, environment, directory),
enabled: Boolean(workspaceId) && Boolean(environment)
queryKey: queryKeys.getSecretFolders({ projectId, environment, path }),
queryFn: async () => fetchProjectFolders(projectId, environment, path),
enabled: Boolean(projectId) && Boolean(environment)
}))
});
@@ -105,15 +105,13 @@ export const useCreateFolder = () => {
const { data } = await apiRequest.post("/api/v1/folders", dto);
return data;
},
onSuccess: (_, { workspaceId, environment, directory }) => {
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path }));
queryClient.invalidateQueries(
queryKeys.getSecretFolders({ workspaceId, environment, directory })
secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId, environment, directory })
);
queryClient.invalidateQueries(
secretSnapshotKeys.count({ workspaceId, environment, directory })
secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path })
);
}
});
@@ -123,24 +121,22 @@ export const useUpdateFolder = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateFolderDTO>({
mutationFn: async ({ directory = "/", folderName, name, environment, workspaceId }) => {
const { data } = await apiRequest.patch(`/api/v1/folders/${folderName}`, {
mutationFn: async ({ path = "/", folderId, name, environment, projectId }) => {
const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, {
name,
environment,
workspaceId,
directory
projectId,
path
});
return data;
},
onSuccess: (_, { workspaceId, environment, directory }) => {
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path }));
queryClient.invalidateQueries(
queryKeys.getSecretFolders({ workspaceId, environment, directory })
secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId, environment, directory })
);
queryClient.invalidateQueries(
secretSnapshotKeys.count({ workspaceId, environment, directory })
secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path })
);
}
});
@@ -150,25 +146,23 @@ export const useDeleteFolder = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TDeleteFolderDTO>({
mutationFn: async ({ directory = "/", folderName, environment, workspaceId }) => {
const { data } = await apiRequest.delete(`/api/v1/folders/${folderName}`, {
mutationFn: async ({ path = "/", folderId, environment, projectId }) => {
const { data } = await apiRequest.delete(`/api/v1/folders/${folderId}`, {
data: {
environment,
workspaceId,
directory
projectId,
path
}
});
return data;
},
onSuccess: (_, { directory = "/", workspaceId, environment }) => {
onSuccess: (_, { path = "/", projectId, environment }) => {
queryClient.invalidateQueries(queryKeys.getSecretFolders({ projectId, environment, path }));
queryClient.invalidateQueries(
queryKeys.getSecretFolders({ workspaceId, environment, directory })
secretSnapshotKeys.list({ workspaceId: projectId, environment, directory: path })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId, environment, directory })
);
queryClient.invalidateQueries(
secretSnapshotKeys.count({ workspaceId, environment, directory })
secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path })
);
}
});

View File

@@ -4,35 +4,35 @@ export type TSecretFolder = {
};
export type TGetProjectFoldersDTO = {
workspaceId: string;
projectId: string;
environment: string;
directory?: string;
path?: string;
};
export type TGetFoldersByEnvDTO = {
environments: string[];
workspaceId: string;
directory?: string;
projectId: string;
path?: string;
};
export type TCreateFolderDTO = {
workspaceId: string;
projectId: string;
environment: string;
folderName: string;
directory?: string;
name: string;
path?: string;
};
export type TUpdateFolderDTO = {
workspaceId: string;
projectId: string;
environment: string;
name: string;
folderName: string;
directory?: string;
folderId: string;
path?: string;
};
export type TDeleteFolderDTO = {
workspaceId: string;
projectId: string;
environment: string;
folderName: string;
directory?: string;
folderId: string;
path?: string;
};

View File

@@ -9,21 +9,21 @@ export const useCreateSecretImport = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretImportDTO>({
mutationFn: async ({ secretImport, environment, workspaceId, directory }) => {
mutationFn: async ({ import: secretImport, environment, projectId, path }) => {
const { data } = await apiRequest.post("/api/v1/secret-imports", {
secretImport,
import: secretImport,
environment,
workspaceId,
directory
projectId,
path
});
return data;
},
onSuccess: (_, { workspaceId, environment, directory }) => {
onSuccess: (_, { environment, projectId, path }) => {
queryClient.invalidateQueries(
secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory })
secretImportKeys.getProjectSecretImports({ projectId, environment, path })
);
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory })
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
);
}
});
@@ -33,21 +33,21 @@ export const useUpdateSecretImport = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretImportDTO>({
mutationFn: async ({ environment, workspaceId, directory, secretImports, id }) => {
const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, {
secretImports,
mutationFn: async ({ environment, import: secretImports, projectId, path, id }) => {
const { data } = await apiRequest.patch(`/api/v1/secret-imports/${id}`, {
import: secretImports,
environment,
workspaceId,
directory
path,
projectId
});
return data;
},
onSuccess: (_, { workspaceId, environment, directory }) => {
onSuccess: (_, { environment, projectId, path }) => {
queryClient.invalidateQueries(
secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory })
secretImportKeys.getProjectSecretImports({ projectId, path, environment })
);
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory })
secretImportKeys.getSecretImportSecrets({ environment, path, projectId })
);
}
});
@@ -57,21 +57,22 @@ export const useDeleteSecretImport = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TDeleteSecretImportDTO>({
mutationFn: async ({ id, secretImportEnv, secretImportPath }) => {
mutationFn: async ({ id, projectId, path, environment }) => {
const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, {
data: {
secretImportPath,
secretImportEnv
projectId,
path,
environment
}
});
return data;
},
onSuccess: (_, { workspaceId, environment, directory }) => {
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory })
secretImportKeys.getProjectSecretImports({ projectId, environment, path })
);
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory })
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
);
}
});

View File

@@ -7,44 +7,44 @@ import {
} from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request";
import { TGetImportedSecrets, TGetSecretImports, TImportedSecrets, TSecretImports } from "./types";
import { TGetImportedSecrets, TGetSecretImports, TImportedSecrets, TSecretImport } from "./types";
export const secretImportKeys = {
getProjectSecretImports: ({ environment, workspaceId, directory }: TGetSecretImports) =>
[{ workspaceId, directory, environment }, "secrets-imports"] as const,
getProjectSecretImports: ({ environment, projectId, path }: TGetSecretImports) =>
[{ projectId, path, environment }, "secrets-imports"] as const,
getSecretImportSecrets: ({
workspaceId,
environment,
directory
projectId,
path
}: Omit<TGetImportedSecrets, "decryptFileKey">) =>
[{ workspaceId, environment, directory }, "secrets-import-sec"] as const
[{ environment, path, projectId }, "secrets-import-sec"] as const
};
const fetchSecretImport = async ({ workspaceId, environment, directory }: TGetSecretImports) => {
const { data } = await apiRequest.get<{ secretImport: TSecretImports }>(
const fetchSecretImport = async ({ projectId, environment, path = "/" }: TGetSecretImports) => {
const { data } = await apiRequest.get<{ secretImports: TSecretImport[] }>(
"/api/v1/secret-imports",
{
params: {
workspaceId,
projectId,
environment,
directory
path
}
}
);
return data.secretImport;
return data.secretImports;
};
export const useGetSecretImports = ({
workspaceId,
environment,
directory = "/",
path = "/",
projectId,
options = {}
}: TGetSecretImports & {
options?: Omit<
UseQueryOptions<
TSecretImports,
TSecretImport[],
unknown,
TSecretImports,
TSecretImport[],
ReturnType<typeof secretImportKeys.getProjectSecretImports>
>,
"queryKey" | "queryFn"
@@ -52,9 +52,9 @@ export const useGetSecretImports = ({
}) =>
useQuery({
...options,
queryKey: secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }),
enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true),
queryFn: () => fetchSecretImport({ workspaceId, environment, directory })
queryKey: secretImportKeys.getProjectSecretImports({ environment, projectId, path }),
enabled: Boolean(projectId) && Boolean(environment) && (options?.enabled ?? true),
queryFn: () => fetchSecretImport({ path, projectId, environment })
});
const fetchImportedSecrets = async (
@@ -76,10 +76,10 @@ const fetchImportedSecrets = async (
};
export const useGetImportedSecrets = ({
workspaceId,
environment,
decryptFileKey,
directory,
path,
projectId,
options = {}
}: TGetImportedSecrets & {
options?: Omit<
@@ -94,16 +94,16 @@ export const useGetImportedSecrets = ({
}) =>
useQuery({
enabled:
Boolean(workspaceId) &&
Boolean(projectId) &&
Boolean(environment) &&
Boolean(decryptFileKey) &&
(options?.enabled ?? true),
queryKey: secretImportKeys.getSecretImportSecrets({
workspaceId,
environment,
directory
path,
projectId
}),
queryFn: () => fetchImportedSecrets(workspaceId, environment, directory),
queryFn: () => fetchImportedSecrets(projectId, environment, path),
select: useCallback(
(data: TImportedSecrets[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;

View File

@@ -1,12 +1,16 @@
import { UserWsKeyPair } from "../keys/types";
import { EncryptedSecret } from "../secrets/types";
export type TSecretImports = {
export type TSecretImport = {
id: string;
workspaceId: string;
environment: string;
folderId: string;
imports: Array<{ environment: string; secretPath: string }>;
importPath: string;
importEnv: {
name: string;
slug: string;
id: string;
};
position: string;
createdAt: string;
updatedAt: string;
};
@@ -19,44 +23,43 @@ export type TImportedSecrets = {
};
export type TGetSecretImports = {
workspaceId: string;
projectId: string;
environment: string;
directory?: string;
path?: string;
};
export type TGetImportedSecrets = {
workspaceId: string;
projectId: string;
environment: string;
directory?: string;
path?: string;
decryptFileKey: UserWsKeyPair;
};
export type TCreateSecretImportDTO = {
workspaceId: string;
projectId: string;
environment: string;
directory?: string;
secretImport: {
path?: string;
import: {
environment: string;
secretPath: string;
path: string;
};
};
export type TUpdateSecretImportDTO = {
id: string;
workspaceId: string;
projectId: string;
environment: string;
directory?: string;
secretImports: Array<{
path?: string;
import: Partial<{
environment: string;
secretPath: string;
path: string;
position: number;
}>;
};
export type TDeleteSecretImportDTO = {
id: string;
workspaceId: string;
projectId: string;
environment: string;
directory?: string;
secretImportPath: string;
secretImportEnv: string;
path?: string;
};

View File

@@ -12,7 +12,7 @@ export type {
} from "./secretApprovalRequest/types";
export { ApprovalStatus, CommitType } from "./secretApprovalRequest/types";
export type { TSecretFolder } from "./secretFolders/types";
export type { TImportedSecrets, TSecretImports } from "./secretImports/types";
export type { TImportedSecrets, TSecretImport } from "./secretImports/types";
export type {
TGetSecretRotationProviders,
TProviderTemplate,

View File

@@ -89,7 +89,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{(roles as TProjectRole[])?.map((role) => {
{roles?.map((role) => {
const { id, name, slug } = role;
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);

View File

@@ -118,7 +118,7 @@ const multiEnvForm2Api = (
const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]);
// if any of them is set in all push it without any condition
PERMISSION_ACTIONS.forEach((action) => {
if (formVal?.all?.[action]) permissions.push({ action, subject });
if (formVal?.all?.[action]) permissions.push({ action, subject: [subject] });
});
if (!isFullAccess) {
@@ -139,7 +139,7 @@ const multiEnvForm2Api = (
if (formVal[slug]?.secretPath)
conditions.secretPath = { $glob: formVal?.[slug]?.secretPath };
permissions.push({ action, subject, conditions });
permissions.push({ action, subject: [subject], conditions });
}
});
});
@@ -156,7 +156,7 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
} else {
Object.entries(actions).forEach(([action, isAllowed]) => {
if (isAllowed) {
permissions.push({ subject: rule, action });
permissions.push({ subject: [rule], action });
}
});
}

View File

@@ -88,9 +88,9 @@ export const SecretMainPage = () => {
});
// fetch folders
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
workspaceId,
projectId: workspaceId,
environment,
directory: secretPath
path: secretPath
});
// fetch secret imports
const {
@@ -98,9 +98,9 @@ export const SecretMainPage = () => {
isLoading: isSecretImportsLoading,
isFetching: isSecretImportsFetching
} = useGetSecretImports({
workspaceId,
projectId: workspaceId,
environment,
directory: secretPath,
path: secretPath,
options: {
enabled: canReadSecret
}
@@ -108,15 +108,15 @@ export const SecretMainPage = () => {
// fetch imported secrets to show user the overriden ones
const { data: importedSecrets } = useGetImportedSecrets({
workspaceId,
projectId: workspaceId,
environment,
decryptFileKey: decryptFileKey!,
directory: secretPath,
path: secretPath,
options: {
enabled: canReadSecret
}
});
// fetch tags
// fech tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
@@ -146,7 +146,7 @@ export const SecretMainPage = () => {
isPaused: !canDoReadRollback
});
const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.imports?.length);
const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.length);
const handleSortToggle = () =>
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));

View File

@@ -112,10 +112,10 @@ export const ActionBar = ({
const handleFolderCreate = async (folderName: string) => {
try {
await createFolder({
folderName,
directory: secretPath,
name: folderName,
path: secretPath,
environment,
workspaceId
projectId: workspaceId
});
handlePopUpClose("addFolder");
createNotification({

View File

@@ -66,11 +66,11 @@ export const CreateSecretImportForm = ({
try {
await createSecretImport({
environment,
workspaceId,
directory: secretPath,
secretImport: {
projectId: workspaceId,
path: secretPath,
import: {
environment: importedEnv,
secretPath: importedSecPath
path: importedSecPath
}
});
onClose();

View File

@@ -42,12 +42,13 @@ export const FolderListView = ({
const handleFolderUpdate = async (newFolderName: string) => {
try {
const { id: folderId } = popUp.updateFolder.data as TSecretFolder;
await updateFolder({
folderName: popUp.updateFolder.data as string,
folderId,
name: newFolderName,
directory: secretPath,
path: secretPath,
environment,
workspaceId
projectId: workspaceId
});
handlePopUpClose("updateFolder");
createNotification({
@@ -65,11 +66,12 @@ export const FolderListView = ({
const handleFolderDelete = async () => {
try {
const { id: folderId } = popUp.deleteFolder.data as TSecretFolder;
await deleteFolder({
folderName: popUp.deleteFolder.data as string,
directory: secretPath,
folderId,
path: secretPath,
environment,
workspaceId
projectId: workspaceId
});
handlePopUpClose("deleteFolder");
createNotification({
@@ -106,13 +108,13 @@ export const FolderListView = ({
.map(({ name, id }) => (
<div
key={id}
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
>
<div className="w-11 px-5 py-3 text-yellow-700 flex items-center">
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
</div>
<div
className="flex-grow px-4 py-3 flex items-center"
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
@@ -122,7 +124,7 @@ export const FolderListView = ({
>
{name}
</div>
<div className="px-3 py-3 flex items-center space-x-4 border-l border-mineshaft-600">
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
@@ -134,8 +136,8 @@ export const FolderListView = ({
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => handlePopUpOpen("updateFolder", name)}
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
@@ -153,8 +155,8 @@ export const FolderListView = ({
ariaLabel="delete-folder"
variant="plain"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => handlePopUpOpen("deleteFolder", name)}
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
@@ -171,14 +173,14 @@ export const FolderListView = ({
<ModalContent title="Edit Folder">
<FolderForm
isEdit
defaultFolderName={popUp.updateFolder.data as string}
defaultFolderName={(popUp.updateFolder?.data as TSecretFolder)?.name}
onUpdateFolder={handleFolderUpdate}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteFolder.isOpen}
deleteKey={popUp.deleteFolder?.data as string}
deleteKey={(popUp.deleteFolder?.data as TSecretFolder)?.name}
title="Do you want to delete this folder?"
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
onDeleteApproved={handleFolderDelete}

View File

@@ -12,17 +12,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
type Props = {
onDelete: (environment: string, secretPath: string) => void;
onDelete: () => void;
environment: string;
secretPath?: string;
importedEnv: string;
importedSecPath: string;
importEnvName: string;
importEnvPath: string;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
searchTerm: string;
id: string;
};
// to show the environment and folder icon
@@ -31,7 +32,7 @@ export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: st
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
{secretPath && (
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
<FontAwesomeIcon icon={faFolder} className="text-md text-green-700" />
<span>{secretPath}</span>
</div>
)}
@@ -39,9 +40,10 @@ export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: st
);
export const SecretImportItem = ({
importedEnv,
importedSecPath,
onDelete,
id,
importEnvName,
importEnvPath,
importedSecrets = [],
searchTerm = "",
secretPath,
@@ -49,10 +51,8 @@ export const SecretImportItem = ({
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id: `${importedEnv}-${importedSecPath}`
id
});
const { currentWorkspace } = useWorkspace();
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) =>
@@ -80,7 +80,7 @@ export const SecretImportItem = ({
return (
<>
<div
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
role="button"
ref={setNodeRef}
tabIndex={0}
@@ -88,13 +88,13 @@ export const SecretImportItem = ({
onClick={() => setIsExpanded.toggle()}
onKeyDown={() => setIsExpanded.toggle()}
>
<div className="w-12 px-4 py-2 flex items-center text-green-700">
<div className="flex w-12 items-center px-4 py-2 text-green-700">
<FontAwesomeIcon icon={faFileImport} />
</div>
<div className="flex-grow px-4 py-2 flex items-center">
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
<div className="flex flex-grow items-center px-4 py-2">
<EnvFolderIcon env={importEnvName || ""} secretPath={importEnvPath} />
</div>
<div className="px-4 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
@@ -107,7 +107,7 @@ export const SecretImportItem = ({
colorSchema="primary"
variant="plain"
ariaLabel="expand"
className="group-hover:opacity-100 opacity-0 p-0"
className="p-0 opacity-0 group-hover:opacity-100"
{...attributes}
{...listeners}
isDisabled={!isAllowed}
@@ -128,10 +128,10 @@ export const SecretImportItem = ({
variant="plain"
colorSchema="danger"
ariaLabel="delete"
className="group-hover:opacity-100 opacity-0 p-0"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
onDelete(importedEnv, importedSecPath);
onDelete();
}}
isDisabled={!isAllowed}
>
@@ -167,7 +167,7 @@ export const SecretImportItem = ({
{importedSecrets
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
.map(({ key, value, overriden }, index) => (
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
<tr key={`${id}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key}
</td>

View File

@@ -17,7 +17,7 @@ import { DeleteActionModal } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
import { TSecretImports } from "@app/hooks/api/secretImports/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { DecryptedSecret } from "@app/hooks/api/types";
import { SecretImportItem } from "./SecretImportItem";
@@ -76,17 +76,15 @@ type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
secretImports?: TSecretImports;
secretImports?: TSecretImport[];
isFetching?: boolean;
secrets?: DecryptedSecret[];
importedSecrets?: TImportedSecrets;
searchTerm: string;
};
type TDeleteSecretImport = { environment: string; secretPath: string };
export const SecretImportListView = ({
secretImports,
secretImports = [],
environment,
workspaceId,
secretPath,
@@ -107,21 +105,11 @@ export const SecretImportListView = ({
useSensor(KeyboardSensor, {})
);
const [items, setItems] = useState(
(secretImports?.imports || [])?.map((dto) => ({
id: `${dto.environment}-${dto.secretPath}`,
...dto
}))
);
const [items, setItems] = useState(secretImports);
useEffect(() => {
if (!isFetching) {
setItems(
(secretImports?.imports || [])?.map((dto) => ({
id: `${dto.environment}-${dto.secretPath}`,
...dto
}))
);
setItems(secretImports);
}
}, [isFetching]);
@@ -129,24 +117,19 @@ export const SecretImportListView = ({
const { mutate: updateSecretImport } = useUpdateSecretImport();
const handleSecretImportDelete = async () => {
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
?.data as TDeleteSecretImport;
const { id: secretImportId } = popUp.deleteSecretImport?.data as { id: string };
try {
if (secretImports?.id) {
await deleteSecretImport({
workspaceId,
environment,
directory: secretPath,
id: secretImports?.id,
secretImportEnv: importEnv,
secretImportPath: impSecPath
});
handlePopUpClose("deleteSecretImport");
createNotification({
type: "success",
text: "Successfully removed secret link"
});
}
await deleteSecretImport({
projectId: workspaceId,
environment,
path: secretPath,
id: secretImportId
});
handlePopUpClose("deleteSecretImport");
createNotification({
type: "success",
text: "Successfully removed secret link"
});
} catch (err) {
console.error(err);
createNotification({
@@ -163,11 +146,13 @@ export const SecretImportListView = ({
const newImportOrder = arrayMove(items, oldIndex, newIndex);
setItems(newImportOrder);
updateSecretImport({
workspaceId,
projectId: workspaceId,
environment,
directory: secretPath,
id: secretImports?.id || "",
secretImports: newImportOrder
path: secretPath,
id: active.id as string,
import: {
position: newIndex + 1
}
});
}
};
@@ -181,26 +166,28 @@ export const SecretImportListView = ({
modifiers={[restrictToVerticalAxis]}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items?.map(({ secretPath: importedSecPath, environment: importedEnv }) => (
<SecretImportItem
searchTerm={searchTerm}
key={`${importedEnv}-${importedSecPath}`}
importedEnv={importedEnv}
importedSecPath={importedSecPath}
importedSecrets={computeImportedSecretRows(
importedEnv,
importedSecPath,
importedSecrets,
secrets,
environments
)}
secretPath={secretPath}
environment={environment}
onDelete={(env, secPath) =>
handlePopUpOpen("deleteSecretImport", { environment: env, secretPath: secPath })
}
/>
))}
{items?.map((item) => {
const { importPath, importEnv, id } = item;
return (
<SecretImportItem
searchTerm={searchTerm}
key={`imported-env-${id}`}
id={id}
importEnvPath={importPath}
importEnvName={importEnv.name}
importedSecrets={computeImportedSecretRows(
importEnv.slug,
importPath,
importedSecrets,
secrets,
environments
)}
secretPath={secretPath}
environment={environment}
onDelete={() => handlePopUpOpen("deleteSecretImport", item)}
/>
);
})}
</SortableContext>
</DndContext>
<DeleteActionModal
@@ -208,8 +195,8 @@ export const SecretImportListView = ({
deleteKey="unlink"
title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
(popUp.deleteSecretImport?.data as TSecretImport)?.importEnv
} of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete}
/>

View File

@@ -208,8 +208,8 @@ export const SecretListView = ({
} = modSecret;
const hasKeyChanged = oldKey !== key;
const tagIds = tags.map(({ id }) => id);
const oldTagIds = orgSecret.tags.map(({ id }) => id);
const tagIds = tags?.map(({ id }) => id);
const oldTagIds = (orgSecret?.tags || []).map(({ id }) => id);
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
const isSharedSecUnchanged =
(

View File

@@ -99,9 +99,9 @@ export const SecretOverviewPage = () => {
decryptFileKey: latestFileKey!
});
const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({
workspaceId,
environments: userAvailableEnvs.map(({ slug }) => slug),
directory: secretPath
projectId: workspaceId,
path: secretPath,
environments: userAvailableEnvs.map(({ slug }) => slug)
});
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
@@ -119,10 +119,10 @@ export const SecretOverviewPage = () => {
const folderName = pathSegment.at(-1);
if (folderName && parentPath) {
await createFolder({
workspaceId,
projectId: workspaceId,
path: secretPath,
environment: env,
directory: parentPath,
folderName
name: folderName
});
}
}
@@ -216,10 +216,10 @@ export const SecretOverviewPage = () => {
const folderName = path.at(-1);
if (folderName && directory) {
await createFolder({
workspaceId,
projectId: workspaceId,
environment: slug,
directory,
folderName
path: secretPath,
name: folderName
});
}
}