From ee29577e6d36d86d9c0112682c7a681aecf068b6 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 20 Mar 2024 14:55:11 +0530 Subject: [PATCH 01/20] feat(server): added dynamic secret database schema --- backend/src/@types/knex.d.ts | 12 ++++ .../20240318164718_dynamic-secret.ts | 56 +++++++++++++++++++ .../src/db/schemas/dynamic-secret-leases.ts | 23 ++++++++ backend/src/db/schemas/dynamic-secrets.ts | 31 ++++++++++ backend/src/db/schemas/index.ts | 2 + backend/src/db/schemas/models.ts | 2 + 6 files changed, 126 insertions(+) create mode 100644 backend/src/db/migrations/20240318164718_dynamic-secret.ts create mode 100644 backend/src/db/schemas/dynamic-secret-leases.ts create mode 100644 backend/src/db/schemas/dynamic-secrets.ts diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 31b80e0e3..29d526e52 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -17,6 +17,12 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, + TDynamicSecretLeases, + TDynamicSecretLeasesInsert, + TDynamicSecretLeasesUpdate, + TDynamicSecrets, + TDynamicSecretsInsert, + TDynamicSecretsUpdate, TGitAppInstallSessions, TGitAppInstallSessionsInsert, TGitAppInstallSessionsUpdate, @@ -340,6 +346,12 @@ declare module "knex/types/tables" { TSecretSnapshotFoldersInsert, TSecretSnapshotFoldersUpdate >; + [TableName.DynamicSecret]: Knex.CompositeTableType; + [TableName.DynamicSecretLease]: Knex.CompositeTableType< + TDynamicSecretLeases, + TDynamicSecretLeasesInsert, + TDynamicSecretLeasesUpdate + >; [TableName.SamlConfig]: Knex.CompositeTableType; [TableName.LdapConfig]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; diff --git a/backend/src/db/migrations/20240318164718_dynamic-secret.ts b/backend/src/db/migrations/20240318164718_dynamic-secret.ts new file mode 100644 index 000000000..ea7186da4 --- /dev/null +++ b/backend/src/db/migrations/20240318164718_dynamic-secret.ts @@ -0,0 +1,56 @@ +import { Knex } from "knex"; + +import { SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.DynamicSecret); + if (!doesTableExist) { + await knex.schema.createTable(TableName.DynamicSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("slug").notNullable(); + t.integer("version").notNullable(); + t.string("type").notNullable(); + t.string("defaultTTL").notNullable(); + t.string("maxTTL"); + t.string("inputIV").notNullable(); + t.text("inputCiphertext").notNullable(); + t.string("inputTag").notNullable(); + t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); + t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); + t.uuid("folderId").notNullable(); + t.boolean("isDeleting").defaultTo(false); + // used for flag why delete is failing + t.string("status"); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.unique(["slug", "folderId"]); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.DynamicSecret); + + const doesTableDynamicSecretLease = await knex.schema.hasTable(TableName.DynamicSecretLease); + if (!doesTableDynamicSecretLease) { + await knex.schema.createTable(TableName.DynamicSecretLease, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("version").notNullable(); + t.string("externalEntityId").notNullable(); + t.string("maxTTL"); + t.datetime("expireAt").notNullable(); + t.uuid("dynamicSecretId").notNullable(); + t.foreign("dynamicSecretId").references("id").inTable(TableName.DynamicSecret).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.DynamicSecretLease); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.DynamicSecretLease); + await knex.schema.dropTableIfExists(TableName.DynamicSecretLease); + + await dropOnUpdateTrigger(knex, TableName.DynamicSecret); + await knex.schema.dropTableIfExists(TableName.DynamicSecret); +} diff --git a/backend/src/db/schemas/dynamic-secret-leases.ts b/backend/src/db/schemas/dynamic-secret-leases.ts new file mode 100644 index 000000000..95efdeb2b --- /dev/null +++ b/backend/src/db/schemas/dynamic-secret-leases.ts @@ -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 DynamicSecretLeasesSchema = z.object({ + id: z.string().uuid(), + version: z.number(), + externalEntityId: z.string(), + maxTTL: z.string().nullable().optional(), + expireAt: z.date(), + dynamicSecretId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TDynamicSecretLeases = z.infer; +export type TDynamicSecretLeasesInsert = Omit, TImmutableDBKeys>; +export type TDynamicSecretLeasesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts new file mode 100644 index 000000000..81da953b3 --- /dev/null +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -0,0 +1,31 @@ +// 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 DynamicSecretsSchema = z.object({ + id: z.string().uuid(), + slug: z.string(), + version: z.number(), + type: z.string(), + defaultTTL: z.string(), + maxTTL: z.string().nullable().optional(), + inputIV: z.string(), + inputCiphertext: z.string(), + inputTag: z.string(), + algorithm: z.string().default("aes-256-gcm"), + keyEncoding: z.string().default("utf8"), + folderId: z.string().uuid(), + isDeleting: z.boolean().default(false).nullable().optional(), + status: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TDynamicSecrets = z.infer; +export type TDynamicSecretsInsert = Omit, TImmutableDBKeys>; +export type TDynamicSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 001fdbf18..efa80cfac 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -3,6 +3,8 @@ export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./dynamic-secret-leases"; +export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; export * from "./identities"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f85feff9c..bda66682e 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -59,6 +59,8 @@ export enum TableName { GitAppOrg = "git_app_org", SecretScanningGitRisk = "secret_scanning_git_risks", TrustedIps = "trusted_ips", + DynamicSecret = "dynamic_secrets", + DynamicSecretLease = "dynamic_secret_leases", // junction tables with tags JnSecretTag = "secret_tag_junction", SecretVersionTag = "secret_version_tag_junction" From 11c7b5c67416ca24d62ba3cfac3c0087f7c062a3 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 20 Mar 2024 14:57:16 +0530 Subject: [PATCH 02/20] feat(server): service for dynamic secret config --- .../dynamic-secret/dynamic-secret-dal.ts | 10 + .../dynamic-secret/dynamic-secret-service.ts | 230 ++++++++++++++++++ .../dynamic-secret/dynamic-secret-types.ts | 36 +++ .../dynamic-secret/providers/index.ts | 6 + .../dynamic-secret/providers/models.ts | 33 +++ .../dynamic-secret/providers/sql-database.ts | 101 ++++++++ 6 files changed, 416 insertions(+) create mode 100644 backend/src/services/dynamic-secret/dynamic-secret-dal.ts create mode 100644 backend/src/services/dynamic-secret/dynamic-secret-service.ts create mode 100644 backend/src/services/dynamic-secret/dynamic-secret-types.ts create mode 100644 backend/src/services/dynamic-secret/providers/index.ts create mode 100644 backend/src/services/dynamic-secret/providers/models.ts create mode 100644 backend/src/services/dynamic-secret/providers/sql-database.ts diff --git a/backend/src/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/services/dynamic-secret/dynamic-secret-dal.ts new file mode 100644 index 000000000..0cc4aca2f --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TDynamicSecretDALFactory = ReturnType; + +export const dynamicSecretDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecret); + return orm; +}; diff --git a/backend/src/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/services/dynamic-secret/dynamic-secret-service.ts new file mode 100644 index 000000000..c9ce0428e --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-service.ts @@ -0,0 +1,230 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; + +import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; +import { + TCreateDynamicSecretDTO, + TDeleteDyanmicSecretDTO, + TListDyanmicSecretsDTO, + TUpdateDynamicSecretDTO +} from "./dynamic-secret-types"; +import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models"; + +type TDynamicSecretServiceFactoryDep = { + dynamicSecretDAL: TDynamicSecretDALFactory; + dynamicSecretLeaseDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: Pick; + folderDAL: Pick; + permissionService: Pick; +}; + +export type TDynamicSecretServiceFactory = ReturnType; + +export const dynamicSecretServiceFactory = ({ + dynamicSecretDAL, + dynamicSecretLeaseDAL, + folderDAL, + dynamicSecretProviders, + permissionService, + dynamicSecretQueueService +}: TDynamicSecretServiceFactoryDep) => { + const create = async ({ + path, + actor, + slug, + actorId, + maxTTL, + provider, + environment, + projectId, + actorOrgId, + defaultTTL, + actorAuthMethod + }: TCreateDynamicSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const existingDynamicSecret = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (existingDynamicSecret) + throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + + const selectedProvider = dynamicSecretProviders[provider.type]; + const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs)); + const dynamicSecretCfg = await dynamicSecretDAL.create({ + type: provider.type, + version: 1, + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + folderId: folder.id, + slug + }); + return dynamicSecretCfg; + }; + + const updateBySlug = async ({ + slug, + maxTTL, + defaultTTL, + inputs, + environment, + projectId, + path, + actor, + actorId, + newSlug, + actorOrgId, + actorAuthMethod + }: TUpdateDynamicSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + if (newSlug) { + const existingDynamicSecret = await dynamicSecretDAL.findOne({ slug: newSlug, folderId: folder.id }); + if (existingDynamicSecret) + throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + } + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; + const updatedInput = await selectedProvider.validateProviderInputs(newInput); + const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(updatedInput)); + const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + slug: newSlug ?? slug + }); + + return updatedDynamicCfg; + }; + + const deleteBySlug = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectId, + slug, + path, + environment + }: TDeleteDyanmicSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + // if leases exist we should flag it as deleting and then remove leases in background + // then delete the main one + if (leases.length) { + const updatedDynamicSecretCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { isDeleting: true }); + await dynamicSecretQueueService.pruneDynamicSecret(updatedDynamicSecretCfg.id); + return updatedDynamicSecretCfg; + } + // if no leases just delete the config + const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id); + return deletedDynamicSecretCfg; + }; + + const list = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectId, + path, + environment + }: TListDyanmicSecretsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id }); + return dynamicSecretCfg; + }; + + return { + create, + updateBySlug, + deleteBySlug, + list + }; +}; diff --git a/backend/src/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/services/dynamic-secret/dynamic-secret-types.ts new file mode 100644 index 000000000..7ce31684a --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-types.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +import { TProjectPermission } from "@app/lib/types"; + +import { DynamicSecretProviderSchema } from "./providers/models"; + +type TProvider = z.infer; +export type TCreateDynamicSecretDTO = { + provider: TProvider; + defaultTTL: string; + maxTTL?: string; + path: string; + environment: string; + slug: string; +} & TProjectPermission; + +export type TUpdateDynamicSecretDTO = { + slug: string; + newSlug?: string; + defaultTTL?: string; + maxTTL?: string; + path: string; + environment: string; + inputs?: TProvider["inputs"]; +} & TProjectPermission; + +export type TDeleteDyanmicSecretDTO = { + slug: string; + path: string; + environment: string; +} & TProjectPermission; + +export type TListDyanmicSecretsDTO = { + path: string; + environment: string; +} & TProjectPermission; diff --git a/backend/src/services/dynamic-secret/providers/index.ts b/backend/src/services/dynamic-secret/providers/index.ts new file mode 100644 index 000000000..d66e60802 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/index.ts @@ -0,0 +1,6 @@ +import { DynamicSecretProviders } from "./models"; +import { SqlDatabaseProvider } from "./sql-database"; + +export const buildDynamicSecretProviders = () => ({ + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider() +}); diff --git a/backend/src/services/dynamic-secret/providers/models.ts b/backend/src/services/dynamic-secret/providers/models.ts new file mode 100644 index 000000000..3b9eb7e16 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/models.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export enum SqlProviders { + Postgres = "postgres" +} + +export const DynamicSecretSqlDBSchema = z.object({ + client: z.nativeEnum(SqlProviders), + host: z.string().toLowerCase(), + port: z.number(), + database: z.string(), + username: z.string(), + password: z.string(), + creationStatement: z.string(), + revocationStatement: z.string(), + renewStatement: z.string(), + ca: z.string().optional() +}); + +export enum DynamicSecretProviders { + SqlDatabase = "sql-database" +} + +export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }) +]); + +export type TDynamicProviderFns = { + create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; + validateProviderInputs: (inputs: object) => Promise; + revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; + renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>; +}; diff --git a/backend/src/services/dynamic-secret/providers/sql-database.ts b/backend/src/services/dynamic-secret/providers/sql-database.ts new file mode 100644 index 000000000..5425f64b6 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/sql-database.ts @@ -0,0 +1,101 @@ +import handlebars from "handlebars"; +import knex from "knex"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { getDbConnectionHost } from "@app/lib/knex"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretSqlDBSchema, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const generatePassword = (size?: number) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%^&*()_+-=[]{}|;,./<>"; + return customAlphabet(charset, 20)(size); +}; + +export const SqlDatabaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const appCfg = getConfig(); + const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1" || dbHost === providerInputs.host) + throw new BadRequestError({ message: "Invalid db host" }); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const db = knex({ + client: providerInputs.client, + connection: { + database: providerInputs.database, + port: providerInputs.port, + host: providerInputs.host, + user: providerInputs.username, + password: providerInputs.password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ssl, + pool: { min: 0, max: 1 } + } + }); + return db; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = alphaNumericNanoId(16); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password: "infisical", + expiration + }); + + await db.raw(creationStatement.toString()); + await db.destroy(); + return { entityId: username, data: { username, password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = entityId; + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); + await db.raw(revokeStatement); + + await db.destroy(); + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration }); + await db.raw(renewStatement); + + await db.destroy(); + return { entityId: username }; + }; + + return { + validateProviderInputs, + create, + revoke, + renew + }; +}; From 69f36d1df62ebb4ce0762420629a72f81b8b6a67 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 20 Mar 2024 14:58:22 +0530 Subject: [PATCH 03/20] feat(server): service for dynamic secret lease and queue service for revocation --- backend/src/lib/errors/index.ts | 12 + backend/src/queue/queue-service.ts | 20 +- .../dynamic-secret-lease-dal.ts | 10 + .../dynamic-secret-lease-queue.ts | 135 +++++++++ .../dynamic-secret-lease-service.ts | 256 ++++++++++++++++++ .../dynamic-secret-lease-types.ts | 27 ++ 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index d93244bbd..18b40acfd 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -59,6 +59,18 @@ export class BadRequestError extends Error { } } +export class DisableRotationErrors extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message: string; name?: string; error?: unknown }) { + super(message); + this.name = name || "DisableRotationErrors"; + this.error = error; + } +} + export class ScimRequestError extends Error { name: string; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 45c135b77..7cb443ae1 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -18,7 +18,8 @@ export enum QueueName { SecretWebhook = "secret-webhook", SecretFullRepoScan = "secret-full-repo-scan", SecretPushEventScan = "secret-push-event-scan", - UpgradeProjectToGhost = "upgrade-project-to-ghost" + UpgradeProjectToGhost = "upgrade-project-to-ghost", + DynamicSecretRevocation = "dynamic-secret-revocation" } export enum QueueJobs { @@ -30,7 +31,9 @@ export enum QueueJobs { TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", SecretScan = "secret-scan", - UpgradeProjectToGhost = "upgrade-project-to-ghost-job" + UpgradeProjectToGhost = "upgrade-project-to-ghost-job", + DynamicSecretRevocation = "dynamic-secret-revocation", + DynamicSecretPruning = "dynamic-secret-pruning" } export type TQueueJobTypes = { @@ -86,6 +89,19 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryInstanceStats; payload: undefined; }; + [QueueName.DynamicSecretRevocation]: + | { + name: QueueJobs.DynamicSecretRevocation; + payload: { + leaseId: string; + }; + } + | { + name: QueueJobs.DynamicSecretPruning; + payload: { + dynamicSecretCfgId: string; + }; + }; }; export type TQueueServiceFactory = ReturnType; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts new file mode 100644 index 000000000..acffc96a7 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TDynamicSecretLeaseDALFactory = ReturnType; + +export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecretLease); + return orm; +}; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts new file mode 100644 index 000000000..c5bb051fe --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -0,0 +1,135 @@ +import { SecretKeyEncoding } from "@app/db/schemas"; +import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; + +type TDynamicSecretLeaseQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + dynamicSecretLeaseDAL: Pick; + dynamicSecretDAL: Pick; + dynamicSecretProviders: Record; +}; + +export type TDynamicSecretLeaseQueueServiceFactory = ReturnType; + +export const dynamicSecretLeaseQueueServiceFactory = ({ + queueService, + dynamicSecretDAL, + dynamicSecretProviders, + dynamicSecretLeaseDAL +}: TDynamicSecretLeaseQueueServiceFactoryDep) => { + const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretPruning, + { dynamicSecretCfgId }, + { + jobId: dynamicSecretCfgId, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const setLeaseRevocation = async (leaseId: string, expiry: number) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretRevocation, + { leaseId }, + { + jobId: leaseId, + delay: expiry, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const unsetLeaseRevocation = async (leaseId: string) => { + await queueService.stopJobById(QueueName.DynamicSecretRevocation, leaseId); + }; + + queueService.start(QueueName.DynamicSecretRevocation, async (job) => { + try { + if (job.name === QueueJobs.DynamicSecretRevocation) { + const { leaseId } = job.data as { leaseId: string }; + logger.info("Dynamic secret lease revocation started: ", leaseId, job.id); + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretLease.dynamicSecretId); + if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return; + } + + if (job.name === QueueJobs.DynamicSecretPruning) { + const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; + logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, job.id); + const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId); + if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" }); + if (!dynamicSecretCfg.isDeleting) throw new DisableRotationErrors({ message: "Document not deleted" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfgId }); + if (dynamicSecretLeases.length) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await Promise.allSettled(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); + await Promise.allSettled( + dynamicSecretLeases.map(({ externalEntityId }) => + selectedProvider.revoke(decryptedStoredInput, externalEntityId) + ) + ); + + await dynamicSecretDAL.deleteById(dynamicSecretCfgId); + } + logger.info("Finished dynamic secret job", job.id); + } catch (error) { + if (error instanceof DisableRotationErrors) { + if (job.id) { + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id); + } + } + } + }); + + queueService.listen(QueueName.AuditLogPrune, "failed", (err) => { + logger.error(err, `${QueueName.AuditLogPrune}: log pruning failed`); + }); + + return { + pruneDynamicSecret, + setLeaseRevocation, + unsetLeaseRevocation + }; +}; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts new file mode 100644 index 000000000..1aace52c1 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -0,0 +1,256 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import ms from "ms"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; + +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue"; +import { + TCreateDynamicSecretLeaseDTO, + TDeleteDynamicSecretLeaseDTO, + TListDynamicSecretLeasesDTO, + TRenewDynamicSecretLeaseDTO +} from "./dynamic-secret-lease-types"; + +type TDynamicSecretLeaseServiceFactoryDep = { + dynamicSecretLeaseDAL: TDynamicSecretLeaseDALFactory; + dynamicSecretDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: TDynamicSecretLeaseQueueServiceFactory; + folderDAL: Pick; + permissionService: Pick; +}; + +export type TDynamicSecretLeaseServiceFactory = ReturnType; + +export const dynamicSecretLeaseServiceFactory = ({ + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL, + folderDAL, + permissionService, + dynamicSecretQueueService +}: TDynamicSecretLeaseServiceFactoryDep) => { + const create = async ({ + environment, + path, + slug, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + ttl + }: TCreateDynamicSecretLeaseDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL; + const { maxTTL } = dynamicSecretCfg; + const expireAt = new Date(new Date().getTime() + ms(selectedTTL)); + if (maxTTL) { + const maxExpiryDate = new Date(new Date().getTime() + ms(maxTTL)); + if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max TTL" }); + } + + const { entityId, data } = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + const dynamicSecretLease = await dynamicSecretLeaseDAL.create({ + expireAt, + version: 1, + dynamicSecretId: dynamicSecretCfg.id, + externalEntityId: entityId + }); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); + return { lease: dynamicSecretLease, data }; + }; + + const renewLease = async ({ + ttl, + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectId, + path, + environment, + leaseId + }: TRenewDynamicSecretLeaseDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL; + const { maxTTL } = dynamicSecretCfg; + const expireAt = new Date(dynamicSecretLease.expireAt.getTime() + ms(selectedTTL)); + if (maxTTL) { + const maxExpiryDate = new Date(dynamicSecretLease.createdAt.getTime() + ms(maxTTL)); + if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max ttl" }); + } + + const { entityId } = await selectedProvider.renew( + decryptedStoredInput, + dynamicSecretLease.externalEntityId, + expireAt.getTime() + ); + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(new Date()) - Number(expireAt)); + const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + expireAt, + externalEntityId: entityId + }); + return updatedDynamicSecretLease; + }; + + const revokeLease = async ({ + leaseId, + environment, + path, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TDeleteDynamicSecretLeaseDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return deletedDynamicSecretLease; + }; + + const listLeases = async ({ + path, + slug, + actor, + actorId, + projectId, + actorOrgId, + environment, + actorAuthMethod + }: TListDynamicSecretLeasesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + 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" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + return dynamicSecretLeases; + }; + + return { + create, + listLeases, + revokeLease, + renewLease + }; +}; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts new file mode 100644 index 000000000..decc7e3d1 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts @@ -0,0 +1,27 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateDynamicSecretLeaseDTO = { + slug: string; + path: string; + environment: string; + ttl?: string; +} & TProjectPermission; + +export type TListDynamicSecretLeasesDTO = { + slug: string; + path: string; + environment: string; +} & TProjectPermission; + +export type TDeleteDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environment: string; +} & TProjectPermission; + +export type TRenewDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environment: string; + ttl?: string; +} & TProjectPermission; From ab48c3b4fe46e0590e5ef042803913dfb496c11a Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 20 Mar 2024 14:59:00 +0530 Subject: [PATCH 04/20] feat(server): completed integration of dynamic server routes --- backend/src/@types/fastify.d.ts | 4 + backend/src/server/routes/index.ts | 34 ++++ .../routes/v1/dynamic-secret-lease-router.ts | 138 +++++++++++++++++ .../server/routes/v1/dynamic-secret-router.ts | 146 ++++++++++++++++++ backend/src/server/routes/v1/index.ts | 10 ++ 5 files changed, 332 insertions(+) create mode 100644 backend/src/server/routes/v1/dynamic-secret-lease-router.ts create mode 100644 backend/src/server/routes/v1/dynamic-secret-router.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 0b59aa8a9..136462cca 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -21,6 +21,8 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TDynamicSecretServiceFactory } from "@app/services/dynamic-secret/dynamic-secret-service"; +import { TDynamicSecretLeaseServiceFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -117,6 +119,8 @@ declare module "fastify" { trustedIp: TTrustedIpServiceFactory; secretBlindIndex: TSecretBlindIndexServiceFactory; telemetry: TTelemetryServiceFactory; + dynamicSecret: TDynamicSecretServiceFactory; + dynamicSecretLease: TDynamicSecretLeaseServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c452bfbd3..90fbd875b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -47,6 +47,12 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { dynamicSecretDALFactory } from "@app/services/dynamic-secret/dynamic-secret-dal"; +import { dynamicSecretServiceFactory } from "@app/services/dynamic-secret/dynamic-secret-service"; +import { buildDynamicSecretProviders } from "@app/services/dynamic-secret/providers"; +import { dynamicSecretLeaseDALFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-dal"; +import { dynamicSecretLeaseQueueServiceFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-queue"; +import { dynamicSecretLeaseServiceFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; @@ -196,6 +202,8 @@ export const registerRoutes = async ( const gitAppOrgDAL = gitAppDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); const licenseDAL = licenseDALFactory(db); + const dynamicSecretDAL = dynamicSecretDALFactory(db); + const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -550,6 +558,30 @@ export const registerRoutes = async ( licenseService }); + const dynamicSecretProviders = buildDynamicSecretProviders(); + const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ + queueService, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL + }); + const dynamicSecretService = dynamicSecretServiceFactory({ + dynamicSecretQueueService, + dynamicSecretDAL, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + folderDAL, + permissionService + }); + const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ + permissionService, + dynamicSecretQueueService, + dynamicSecretDAL, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + folderDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -591,6 +623,8 @@ export const registerRoutes = async ( secretApprovalPolicy: sapService, secretApprovalRequest: sarService, secretRotation: secretRotationService, + dynamicSecret: dynamicSecretService, + dynamicSecretLease: dynamicSecretLeaseService, snapshot: snapshotService, saml: samlService, ldap: ldapService, diff --git a/backend/src/server/routes/v1/dynamic-secret-lease-router.ts b/backend/src/server/routes/v1/dynamic-secret-lease-router.ts new file mode 100644 index 000000000..cf39bdd78 --- /dev/null +++ b/backend/src/server/routes/v1/dynamic-secret-lease-router.ts @@ -0,0 +1,138 @@ +import ms from "ms"; +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + slug: z.string(), + projectId: z.string(), + ttl: z + .string() + .optional() + .refine((val) => typeof val === "undefined" || ms(val) > 0, "TTL must be a positive number"), + path: z.string().default("/"), + environment: z.string() + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema, + data: z.unknown() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { data, lease } = await server.services.dynamicSecretLease.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return { lease, data }; + } + }); + + server.route({ + url: "/:leaseId", + method: "DELETE", + schema: { + params: z.object({ + leaseId: z.string() + }), + body: z.object({ + projectId: z.string(), + path: z.string(), + environment: z.string() + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const lease = await server.services.dynamicSecretLease.revokeLease({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + leaseId: req.params.leaseId, + ...req.body + }); + return { lease }; + } + }); + + server.route({ + url: "/:leaseId/renew", + method: "POST", + schema: { + params: z.object({ + leaseId: z.string() + }), + body: z.object({ + projectId: z.string(), + path: z.string(), + environment: z.string() + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const lease = await server.services.dynamicSecretLease.renewLease({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + leaseId: req.params.leaseId, + ...req.body + }); + return { lease }; + } + }); + + server.route({ + url: "/:slug", + method: "GET", + schema: { + params: z.object({ + slug: z.string() + }), + querystring: z.object({ + projectId: z.string(), + path: z.string(), + environment: z.string() + }), + response: { + 200: z.object({ + leases: DynamicSecretLeasesSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const leases = await server.services.dynamicSecretLease.listLeases({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.params.slug, + ...req.query + }); + return { leases }; + } + }); +}; diff --git a/backend/src/server/routes/v1/dynamic-secret-router.ts b/backend/src/server/routes/v1/dynamic-secret-router.ts new file mode 100644 index 000000000..9c56f8543 --- /dev/null +++ b/backend/src/server/routes/v1/dynamic-secret-router.ts @@ -0,0 +1,146 @@ +import ms from "ms"; +import { z } from "zod"; + +import { DynamicSecretsSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { DynamicSecretProviderSchema } from "@app/services/dynamic-secret/providers/models"; + +export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + projectId: z.string(), + provider: DynamicSecretProviderSchema, + defaultTTL: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + maxTTL: z + .string() + .optional() + .refine((val) => typeof val === "undefined" || ms(val) > 0, "Max TTL must be a positive number"), + path: z.string().default("/"), + environment: z.string(), + slug: z.string().toLowerCase() + }), + response: { + 200: z.object({ + dynamicSecret: DynamicSecretsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/:slug", + method: "PATCH", + schema: { + params: z.object({ + slug: z.string() + }), + body: z.object({ + projectId: z.string(), + inputs: z.any().optional(), + defaultTTL: z + .string() + .optional() + .refine((val) => typeof val === "undefined" || ms(val) > 0, "TTL must be a positive number"), + maxTTL: z + .string() + .optional() + .refine((val) => typeof val === "undefined" || ms(val) > 0, "Max TTL must be a positive number"), + path: z.string(), + environment: z.string(), + newSlug: z.string() + }), + response: { + 200: z.object({ + dynamicSecret: DynamicSecretsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.updateBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.params.slug, + ...req.body + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/:slug", + method: "DELETE", + schema: { + params: z.object({ + slug: z.string() + }), + body: z.object({ + projectId: z.string(), + path: z.string(), + environment: z.string() + }), + response: { + 200: z.object({ + dynamicSecret: DynamicSecretsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.deleteBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.params.slug, + ...req.body + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectId: z.string(), + path: z.string(), + environment: z.string() + }), + response: { + 200: z.object({ + dynamicSecrets: DynamicSecretsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfgs = await server.services.dynamicSecret.list({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + return { dynamicSecrets: dynamicSecretCfgs }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fbc68d974..917514b72 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,6 +1,8 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; +import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; +import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; @@ -52,6 +54,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/workspace" } ); + await server.register( + async (dynamicSecretRouter) => { + await dynamicSecretRouter.register(registerDynamicSecretRouter); + await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" }); + }, + { prefix: "/dynamic-secrets" } + ); + await server.register(registerProjectBotRouter, { prefix: "/bot" }); await server.register(registerIntegrationRouter, { prefix: "/integration" }); await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); From 177cd385ccc8be752141257393aea246fadd829e Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 21 Mar 2024 20:20:24 +0530 Subject: [PATCH 05/20] feat(ui): added dynamic secret and lease api hook --- .../src/components/features/TtlFormLabel.tsx | 36 ++++++++ frontend/src/components/features/index.tsx | 1 + .../components/v2/FormControl/FormControl.tsx | 16 +++- frontend/src/hooks/api/dynamicSecret/index.ts | 2 + .../src/hooks/api/dynamicSecret/mutation.ts | 62 ++++++++++++++ .../src/hooks/api/dynamicSecret/queries.ts | 62 ++++++++++++++ frontend/src/hooks/api/dynamicSecret/types.ts | 83 +++++++++++++++++++ .../src/hooks/api/dynamicSecretLease/index.ts | 6 ++ .../hooks/api/dynamicSecretLease/mutation.ts | 72 ++++++++++++++++ .../hooks/api/dynamicSecretLease/queries.ts | 37 +++++++++ .../src/hooks/api/dynamicSecretLease/types.ts | 47 +++++++++++ frontend/src/hooks/api/index.tsx | 2 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useTimedReset.tsx | 25 ++++++ 14 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/features/TtlFormLabel.tsx create mode 100644 frontend/src/components/features/index.tsx create mode 100644 frontend/src/hooks/api/dynamicSecret/index.ts create mode 100644 frontend/src/hooks/api/dynamicSecret/mutation.ts create mode 100644 frontend/src/hooks/api/dynamicSecret/queries.ts create mode 100644 frontend/src/hooks/api/dynamicSecret/types.ts create mode 100644 frontend/src/hooks/api/dynamicSecretLease/index.ts create mode 100644 frontend/src/hooks/api/dynamicSecretLease/mutation.ts create mode 100644 frontend/src/hooks/api/dynamicSecretLease/queries.ts create mode 100644 frontend/src/hooks/api/dynamicSecretLease/types.ts create mode 100644 frontend/src/hooks/useTimedReset.tsx diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx new file mode 100644 index 000000000..14382abb4 --- /dev/null +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -0,0 +1,36 @@ +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FormLabel, Tooltip } from "../v2"; + +// To give users example of possible values of TTL +export const TtlFormLabel = ({ label }: { label: string }) => ( +
+ + 1m, 2h, 3d.{" "} + + More + + + } + > + + + } + /> +
+); diff --git a/frontend/src/components/features/index.tsx b/frontend/src/components/features/index.tsx new file mode 100644 index 000000000..f00724b65 --- /dev/null +++ b/frontend/src/components/features/index.tsx @@ -0,0 +1 @@ +export { TtlFormLabel } from "./TtlFormLabel"; diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 9f4e33cac..35f85383d 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -7,21 +7,23 @@ import { twMerge } from "tailwind-merge"; export type FormLabelProps = { id?: string; isRequired?: boolean; + isOptional?:boolean; label?: ReactNode; icon?: ReactNode; className?: string; }; -export const FormLabel = ({ id, label, isRequired, icon, className }: FormLabelProps) => ( +export const FormLabel = ({ id, label, isRequired, icon, className,isOptional }: FormLabelProps) => ( {label} {isRequired && *} + {isOptional && - Optional} {icon && ( {icon} @@ -54,6 +56,7 @@ export const FormHelperText = ({ isError, text }: FormHelperTextProps) => ( export type FormControlProps = { id?: string; isRequired?: boolean; + isOptional?: boolean; isError?: boolean; label?: ReactNode; helperText?: ReactNode; @@ -66,6 +69,7 @@ export type FormControlProps = { export const FormControl = ({ children, isRequired, + isOptional, label, helperText, errorText, @@ -77,7 +81,13 @@ export const FormControl = ({ return (
{typeof label === "string" ? ( - + ) : ( label )} diff --git a/frontend/src/hooks/api/dynamicSecret/index.ts b/frontend/src/hooks/api/dynamicSecret/index.ts new file mode 100644 index 000000000..58011629e --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecret/index.ts @@ -0,0 +1,2 @@ +export { useCreateDynamicSecret, useDeleteDynamicSecret, useUpdateDynamicSecret } from "./mutation"; +export { useGetDynamicSecretDetails,useGetDynamicSecrets } from "./queries"; diff --git a/frontend/src/hooks/api/dynamicSecret/mutation.ts b/frontend/src/hooks/api/dynamicSecret/mutation.ts new file mode 100644 index 000000000..0c317a2eb --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecret/mutation.ts @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { dynamicSecretKeys } from "./queries"; +import { + TCreateDynamicSecretDTO, + TDeleteDynamicSecretDTO, + TDynamicSecret, + TUpdateDynamicSecretDTO +} from "./types"; + +export const useCreateDynamicSecret = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateDynamicSecretDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ dynamicSecret: TDynamicSecret }>( + "/api/v1/dynamic-secrets", + dto + ); + return data.dynamicSecret; + }, + onSuccess: (_, { path, environment, projectId }) => { + queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectId, environment })); + } + }); +}; + +export const useUpdateDynamicSecret = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateDynamicSecretDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch<{ dynamicSecret: TDynamicSecret }>( + `/api/v1/dynamic-secrets/${dto.slug}`, + dto + ); + return data.dynamicSecret; + }, + onSuccess: (_, { path, environment, projectId }) => { + queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectId, environment })); + } + }); +}; + +export const useDeleteDynamicSecret = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteDynamicSecretDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete<{ dynamicSecret: TDynamicSecret }>( + `/api/v1/dynamic-secrets/${dto.slug}`, + { data: dto } + ); + return data.dynamicSecret; + }, + onSuccess: (_, { path, environment, projectId }) => { + queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectId, environment })); + } + }); +}; diff --git a/frontend/src/hooks/api/dynamicSecret/queries.ts b/frontend/src/hooks/api/dynamicSecret/queries.ts new file mode 100644 index 000000000..84099d7d9 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecret/queries.ts @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TDetailsDynamicSecretDTO, TDynamicSecret, TListDynamicSecretDTO } from "./types"; + +export const dynamicSecretKeys = { + list: ({ + projectId, + environment, + path + }: Pick) => + [{ projectId, environment, path }, "dynamic-secrets"] as const, + details: ({ path, environment, projectId, slug }: TDetailsDynamicSecretDTO) => + [{ projectId, path, environment, slug }, "dynamic-secret-details"] as const +}; + +export const useGetDynamicSecrets = ({ projectId, environment, path }: TListDynamicSecretDTO) => { + return useQuery({ + queryKey: dynamicSecretKeys.list({ path, environment, projectId }), + enabled: Boolean(projectId && environment && path), + queryFn: async () => { + const { data } = await apiRequest.get<{ dynamicSecrets: TDynamicSecret[] }>( + "/api/v1/dynamic-secrets", + { + params: { + projectId, + environment, + path + } + } + ); + + return data.dynamicSecrets; + } + }); +}; + +export const useGetDynamicSecretDetails = ({ + projectId, + environment, + path, + slug +}: TDetailsDynamicSecretDTO) => { + return useQuery({ + queryKey: dynamicSecretKeys.details({ path, environment, projectId, slug }), + enabled: Boolean(projectId && environment && path && slug), + queryFn: async () => { + const { data } = await apiRequest.get<{ + dynamicSecret: TDynamicSecret & { inputs: unknown }; + }>(`/api/v1/dynamic-secrets/${slug}`, { + params: { + projectId, + environment, + path + } + }); + + return data.dynamicSecret; + } + }); +}; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts new file mode 100644 index 000000000..821b61cc4 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -0,0 +1,83 @@ +export enum DynamicSecretStatus { + Deleting = "Revocation in process", + FailedDeletion = "Failed to delete" +} +// TODO(akhilmhdh): When we switch to monorepo all the server api ts will be in a shared repo +export type TDynamicSecret = { + id: string; + slug: string; + type: DynamicSecretProviders; + createdAt: string; + updatedAt: string; + defaultTTL: string; + status?: DynamicSecretStatus; + statusDetails?: string; + maxTTL: string; +}; + +export enum DynamicSecretProviders { + SqlDatabase = "sql-database" +} + +export enum SqlProviders { + Postgres = "postgres" +} + +export type TDynamicSecretProvider = { + type: DynamicSecretProviders; + inputs: { + client: SqlProviders; + host: string; + port: number; + database: string; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + renewStatement: string; + ca?: string | undefined; + }; +}; + +export type TCreateDynamicSecretDTO = { + projectId: string; + provider: TDynamicSecretProvider; + defaultTTL: string; + maxTTL?: string; + path: string; + environment: string; + slug: string; +}; + +export type TUpdateDynamicSecretDTO = { + slug: string; + projectId: string; + path: string; + environment: string; + data: { + newSlug?: string; + defaultTTL?: string; + maxTTL?: string | null; + inputs?: unknown; + }; +}; + +export type TListDynamicSecretDTO = { + projectId: string; + path: string; + environment: string; +}; + +export type TDeleteDynamicSecretDTO = { + projectId: string; + path: string; + environment: string; + slug: string; +}; + +export type TDetailsDynamicSecretDTO = { + projectId: string; + path: string; + environment: string; + slug: string; +}; diff --git a/frontend/src/hooks/api/dynamicSecretLease/index.ts b/frontend/src/hooks/api/dynamicSecretLease/index.ts new file mode 100644 index 000000000..7f2d6852f --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecretLease/index.ts @@ -0,0 +1,6 @@ +export { + useCreateDynamicSecretLease, + useRenewDynamicSecretLease, + useRevokeDynamicSecretLease +} from "./mutation"; +export { useGetDynamicSecretLeases } from "./queries"; diff --git a/frontend/src/hooks/api/dynamicSecretLease/mutation.ts b/frontend/src/hooks/api/dynamicSecretLease/mutation.ts new file mode 100644 index 000000000..c24e3ed31 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecretLease/mutation.ts @@ -0,0 +1,72 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { dynamicSecretLeaseKeys } from "./queries"; +import { + TCreateDynamicSecretLeaseDTO, + TDynamicSecretLease, + TRenewDynamicSecretLeaseDTO, + TRevokeDynamicSecretLeaseDTO +} from "./types"; + +export const useCreateDynamicSecretLease = () => { + const queryClient = useQueryClient(); + + return useMutation< + { lease: TDynamicSecretLease; data: unknown }, + {}, + TCreateDynamicSecretLeaseDTO + >({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>( + "/api/v1/dynamic-secrets/leases", + dto + ); + return data; + }, + onSuccess: (_, { path, environment, projectId, slug }) => { + queryClient.invalidateQueries( + dynamicSecretLeaseKeys.list({ path, projectId, environment, slug }) + ); + } + }); +}; + +export const useRenewDynamicSecretLease = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TRenewDynamicSecretLeaseDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ lease: TDynamicSecretLease }>( + `/api/v1/dynamic-secrets/leases/${dto.leaseId}/renew`, + dto + ); + return data.lease; + }, + onSuccess: (_, { path, environment, projectId, slug }) => { + queryClient.invalidateQueries( + dynamicSecretLeaseKeys.list({ path, projectId, environment, slug }) + ); + } + }); +}; + +export const useRevokeDynamicSecretLease = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TRevokeDynamicSecretLeaseDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete<{ lease: TDynamicSecretLease }>( + `/api/v1/dynamic-secrets/leases/${dto.leaseId}`, + { data: dto } + ); + return data.lease; + }, + onSuccess: (_, { path, environment, projectId, slug }) => { + queryClient.invalidateQueries( + dynamicSecretLeaseKeys.list({ path, projectId, environment, slug }) + ); + } + }); +}; diff --git a/frontend/src/hooks/api/dynamicSecretLease/queries.ts b/frontend/src/hooks/api/dynamicSecretLease/queries.ts new file mode 100644 index 000000000..0de9620c7 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecretLease/queries.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TDynamicSecretLease, TListDynamicSecretLeaseDTO } from "./types"; + +export const dynamicSecretLeaseKeys = { + list: ({ projectId, environment, path, slug }: TListDynamicSecretLeaseDTO) => + [{ projectId, environment, path, slug }, "dynamic-secret-leases"] as const +}; + +export const useGetDynamicSecretLeases = ({ + projectId, + environment, + path, + slug, + enabled = true +}: TListDynamicSecretLeaseDTO) => { + return useQuery({ + queryKey: dynamicSecretLeaseKeys.list({ path, environment, projectId, slug }), + enabled: Boolean(projectId && environment && path && slug && enabled), + queryFn: async () => { + const { data } = await apiRequest.get<{ leases: TDynamicSecretLease[] }>( + `/api/v1/dynamic-secrets/leases/${slug}`, + { + params: { + projectId, + environment, + path + } + } + ); + + return data.leases; + } + }); +}; diff --git a/frontend/src/hooks/api/dynamicSecretLease/types.ts b/frontend/src/hooks/api/dynamicSecretLease/types.ts new file mode 100644 index 000000000..b888970c2 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecretLease/types.ts @@ -0,0 +1,47 @@ +export enum DynamicSecretLeaseStatus { + FailedDeletion = "Failed to delete" +} + +export type TDynamicSecretLease = { + id: string; + version: number; + expireAt: string; + dynamicSecretId: string; + status?: DynamicSecretLeaseStatus; + statusDetails?: string; + createdAt: string; + updatedAt: string; +}; + +export type TCreateDynamicSecretLeaseDTO = { + slug: string; + projectId: string; + ttl?: string; + path: string; + environment: string; +}; + +export type TRenewDynamicSecretLeaseDTO = { + leaseId: string; + slug: string; + ttl?: string; + projectId: string; + path: string; + environment: string; +}; + +export type TListDynamicSecretLeaseDTO = { + slug: string; + projectId: string; + path: string; + environment: string; + enabled?: boolean; +}; + +export type TRevokeDynamicSecretLeaseDTO = { + leaseId: string; + slug: string; + projectId: string; + path: string; + environment: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4ec8db3ac..48d85c285 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -3,6 +3,8 @@ export * from "./apiKeys"; export * from "./auditLogs"; export * from "./auth"; export * from "./bots"; +export * from "./dynamicSecret"; +export * from "./dynamicSecretLease"; export * from "./identities"; export * from "./incidentContacts"; export * from "./integrationAuth"; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 98fbe2413..578e9ffc6 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -3,4 +3,5 @@ export { useLeaveConfirm } from "./useLeaveConfirm"; export { usePersistentState } from "./usePersistentState"; export { usePopUp } from "./usePopUp"; export { useSyntaxHighlight } from "./useSyntaxHighlight"; +export { useTimedReset } from "./useTimedReset"; export { useToggle } from "./useToggle"; diff --git a/frontend/src/hooks/useTimedReset.tsx b/frontend/src/hooks/useTimedReset.tsx new file mode 100644 index 000000000..60e286978 --- /dev/null +++ b/frontend/src/hooks/useTimedReset.tsx @@ -0,0 +1,25 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; + +type Props = { + initialState: T; + delay?: number; +}; + +// this hook is used when you need to reset the state to previous one after a particular time +// usecase#1: To make copy to copied and back to copy in clipboard operation +export const useTimedReset = ({ + delay = 2000, + initialState +}: Props): [T, boolean, Dispatch>] => { + const [state, setState] = useState(initialState); + useEffect(() => { + let timer: NodeJS.Timeout; + if (state !== initialState) { + timer = setTimeout(() => setState(initialState), delay); + } + return () => clearTimeout(timer); + }, [state]); + + // state, isChaning, setState + return [state, state !== initialState, setState]; +}; From e201e80a066da8cdad2479fdaea975413c92da1c Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 21 Mar 2024 20:20:59 +0530 Subject: [PATCH 06/20] feat(ui): completed ui for dynamic secret --- frontend/package-lock.json | 31 +- frontend/package.json | 1 + .../SecretMainPage/SecretMainPage.store.tsx | 1 + .../views/SecretMainPage/SecretMainPage.tsx | 27 +- .../components/ActionBar/ActionBar.tsx | 31 +- .../CreateDynamicSecretForm.tsx | 105 +++++ .../SqlDatabaseInputForm.tsx | 346 +++++++++++++++++ .../CreateDynamicSecretForm/index.tsx | 1 + .../CreateDynamicSecretLease.tsx | 185 +++++++++ .../DynamicSecretLease.tsx | 213 +++++++++++ .../DynamicSecretListView.tsx | 259 +++++++++++++ .../EditDynamicSecretForm.tsx | 61 +++ .../EditDynamicSecretSqlProviderForm.tsx | 361 ++++++++++++++++++ .../EditDynamicSecretForm/index.tsx | 1 + .../RenewDynamicSecretLease.tsx | 107 ++++++ .../DynamicSecretListView/index.tsx | 1 + 16 files changed, 1713 insertions(+), 18 deletions(-) create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/index.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretLease.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/index.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/RenewDynamicSecretLease.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/index.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e30edfc3..0d8eaa794 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -66,6 +67,7 @@ "jwt-decode": "^3.1.2", "lottie-react": "^2.4.0", "markdown-it": "^13.0.1", + "ms": "^2.1.3", "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", @@ -11455,6 +11457,11 @@ } } }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -13759,9 +13766,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -15126,9 +15133,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/ipaddr.js": { @@ -17518,9 +17525,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multipipe": { "version": "1.0.2", @@ -21287,12 +21294,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6a1666f05..def2a968b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -74,6 +74,7 @@ "jwt-decode": "^3.1.2", "lottie-react": "^2.4.0", "markdown-it": "^13.0.1", + "ms": "^2.1.3", "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.store.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.store.tsx index a92960b31..e5a214fb0 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.store.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.store.tsx @@ -31,6 +31,7 @@ const createSelectedSecretStore: StateCreator = (set) => ({ export enum PopUpNames { CreateSecretForm = "create-secret-form" } + type PopUpState = { popUp: Record; popUpActions: { diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index a06ceda9d..6d82b160d 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -17,6 +17,7 @@ import { } from "@app/context"; import { usePopUp } from "@app/hooks"; import { + useGetDynamicSecrets, useGetImportedSecretsSingleEnv, useGetProjectFolders, useGetProjectSecrets, @@ -31,6 +32,7 @@ import { import { ProjectIndexSecretsSection } from "../SecretOverviewPage/components/ProjectIndexSecretsSection"; import { ActionBar } from "./components/ActionBar"; import { CreateSecretForm } from "./components/CreateSecretForm"; +import { DynamicSecretListView } from "./components/DynamicSecretListView"; import { FolderListView } from "./components/FolderListView"; import { PitDrawer } from "./components/PitDrawer"; import { SecretDropzone } from "./components/SecretDropzone"; @@ -103,12 +105,14 @@ export const SecretMainPage = () => { enabled: canReadSecret } }); + // fetch folders const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({ projectId: workspaceId, environment, path: secretPath }); + // fetch secret imports const { data: secretImports, @@ -133,6 +137,13 @@ export const SecretMainPage = () => { enabled: canReadSecret } }); + + const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({ + projectId: workspaceId, + environment, + path: secretPath + }); + // fech tags const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); @@ -163,7 +174,9 @@ export const SecretMainPage = () => { isPaused: !canDoReadRollback }); - const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.length); + const isNotEmtpy = Boolean( + secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length + ); const handleSortToggle = () => setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC)); @@ -213,7 +226,8 @@ export const SecretMainPage = () => { // loading screen when u have permission const loadingOnAccess = - canReadSecret && (isSecretsLoading || isSecretImportsLoading || isFoldersLoading); + canReadSecret && + (isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading); // loading screen when you don't have permission but as folder's is viewable need to wait for that const loadingOnDenied = !canReadSecret && isFoldersLoading; if (loadingOnAccess || loadingOnDenied) { @@ -298,6 +312,15 @@ export const SecretMainPage = () => { secretPath={secretPath} sortDir={sortDir} /> + {canReadSecret && ( + + )} {canReadSecret && ( { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", + "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", "misc", @@ -311,7 +314,6 @@ export const ActionBar = ({ )} - handlePopUpToggle("misc", isOpen)} @@ -347,6 +349,26 @@ export const ActionBar = ({ )} + + {(isAllowed) => ( + + )} + handlePopUpClose("addSecretImport")} onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} /> + handlePopUpToggle("addDynamicSecret", isOpen)} + workspaceId={workspaceId} + environment={environment} + secretPath={secretPath} + /> handlePopUpToggle("addFolder", isOpen)} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx new file mode 100644 index 000000000..8f2ab811d --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { faDatabase } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + workspaceId: string; + environment: string; + secretPath: string; +}; + +enum WizardSteps { + SelectProvider = "select-provider", + ProviderInputs = "provider-inputs" +} + +export const CreateDynamicSecretForm = ({ + isOpen, + onToggle, + workspaceId, + environment, + secretPath +}: Props) => { + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectProvider); + const [selectedProvider, setSelectedProvider] = useState(null); + + const handleFormReset = (state: boolean = false) => { + onToggle(state); + setWizardStep(WizardSteps.SelectProvider); + setSelectedProvider(null); + }; + + return ( + handleFormReset(state)}> + + + {wizardStep === WizardSteps.SelectProvider && ( + +
Pick a service to connect
+
+
{ + setSelectedProvider(DynamicSecretProviders.SqlDatabase); + setWizardStep(WizardSteps.ProviderInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedProvider(DynamicSecretProviders.SqlDatabase); + setWizardStep(WizardSteps.ProviderInputs); + } + }} + > + +
+ SQL +
+ Database +
+
+
+
+ )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.SqlDatabase && ( + + + + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx new file mode 100644 index 000000000..5eb6086d3 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx @@ -0,0 +1,346 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { TtlFormLabel } from "@app/components/features"; +import { + Button, + FormControl, + Input, + SecretInput, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + client: z.nativeEnum(SqlProviders), + host: z.string().toLowerCase().min(1), + port: z.number(), + database: z.string().min(1), + username: z.string().min(1), + password: z.string().min(1), + creationStatement: z.string().min(1), + revocationStatement: z.string().min(1), + renewStatement: z.string().min(1), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + slug: z.string().toLowerCase() +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectId: string; + environment: string; +}; + +export const SqlDatabaseInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectId +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema) + }); + const { createNotification } = useNotificationContext(); + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ slug, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.SqlDatabase, inputs: provider }, + maxTTL, + slug, + path: secretPath, + defaultTTL, + projectId, + environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
Configuration
+
+ ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + +