diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 0b59aa8a9..8f5c12e3a 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"; @@ -62,7 +64,7 @@ declare module "fastify" { authMethod: ActorAuthMethod; type: ActorType; id: string; - orgId?: string; + orgId: string; }; // passport data passportUser: { @@ -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/@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..743744a03 --- /dev/null +++ b/backend/src/db/migrations/20240318164718_dynamic-secret.ts @@ -0,0 +1,58 @@ +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("name").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(); + // for background process communication + t.string("status"); + t.string("statusDetails"); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.unique(["name", "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.datetime("expireAt").notNullable(); + // for background process communication + t.string("status"); + t.string("statusDetails"); + 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..8c16bcb55 --- /dev/null +++ b/backend/src/db/schemas/dynamic-secret-leases.ts @@ -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 DynamicSecretLeasesSchema = z.object({ + id: z.string().uuid(), + version: z.number(), + externalEntityId: z.string(), + expireAt: z.date(), + status: z.string().nullable().optional(), + statusDetails: z.string().nullable().optional(), + 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..b27da396c --- /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(), + name: 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(), + status: z.string().nullable().optional(), + statusDetails: 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" diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 965aa94e1..e3424b6a9 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -146,7 +146,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { offset: req.query.startIndex, limit: req.query.count, filter: req.query.filter, - orgId: req.permission.orgId as string + orgId: req.permission.orgId }); return users; } @@ -184,7 +184,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const user = await req.server.services.scim.getScimUser({ userId: req.params.userId, - orgId: req.permission.orgId as string + orgId: req.permission.orgId }); return user; } @@ -243,7 +243,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { email: primaryEmail, firstName: req.body.name.givenName, lastName: req.body.name.familyName, - orgId: req.permission.orgId as string + orgId: req.permission.orgId }); return user; @@ -280,7 +280,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const user = await req.server.services.scim.updateScimUser({ userId: req.params.userId, - orgId: req.permission.orgId as string, + orgId: req.permission.orgId, operations: req.body.Operations }); return user; @@ -330,7 +330,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const user = await req.server.services.scim.replaceScimUser({ userId: req.params.userId, - orgId: req.permission.orgId as string, + orgId: req.permission.orgId, active: req.body.active }); return user; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8dca96737..104d1465b 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -15,6 +15,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ membersUsed: 0, environmentLimit: null, environmentsUsed: 0, + dynamicSecret: false, secretVersioning: true, pitRecovery: false, ipAllowlisting: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index f8ed8aff3..735d8458e 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -27,6 +27,7 @@ export type TFeatureSet = { tier: -1; workspaceLimit: null; workspacesUsed: 0; + dynamicSecret: false; memberLimit: null; membersUsed: 0; environmentLimit: null; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index cd4ee6ff7..0f121e7c9 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -305,6 +305,83 @@ export const AUDIT_LOGS = { } } as const; +export const DYNAMIC_SECRETS = { + LIST: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from." + }, + LIST_LEAES_BY_NAME: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + name: "The name of the dynamic secret." + }, + GET_BY_NAME: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + name: "The name of the dynamic secret." + }, + CREATE: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to create the dynamic secret in.", + path: "The path to create the dynamic secret in.", + name: "The name of the dynamic secret.", + provider: "The type of dynamic secret.", + defaultTTL: "The default TTL that will be applied for all the leases.", + maxTTL: "The maximum limit a TTL can be leases or renewed." + }, + UPDATE: { + projectSlug: "The slug of the project to update dynamic secret in.", + environmentSlug: "The slug of the environment to update the dynamic secret in.", + path: "The path to update the dynamic secret in.", + name: "The name of the dynamic secret.", + inputs: "The new partial values for the configurated provider of the dynamic secret", + defaultTTL: "The default TTL that will be applied for all the leases.", + maxTTL: "The maximum limit a TTL can be leases or renewed.", + newName: "The new name for the dynamic secret." + }, + DELETE: { + projectSlug: "The slug of the project to delete dynamic secret in.", + environmentSlug: "The slug of the environment to delete the dynamic secret in.", + path: "The path to delete the dynamic secret in.", + name: "The name of the dynamic secret.", + isForced: + "A boolean flag to delete the the dynamic secret from infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally." + } +} as const; + +export const DYNAMIC_SECRET_LEASES = { + GET_BY_LEASEID: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + leaseId: "The ID of the dynamic secret lease." + }, + CREATE: { + projectSlug: "The slug of the project of the dynamic secret in.", + environmentSlug: "The slug of the environment of the dynamic secret in.", + path: "The path of the dynamic secret in.", + dynamicSecretName: "The name of the dynamic secret.", + ttl: "The lease lifetime ttl. If not provided the default TTL of dynamic secret will be used." + }, + RENEW: { + projectSlug: "The slug of the project of the dynamic secret in.", + environmentSlug: "The slug of the environment of the dynamic secret in.", + path: "The path of the dynamic secret in.", + leaseId: "The ID of the dynamic secret lease.", + ttl: "The renew TTL that gets added with current expiry (ensure it's below max TTL) for a total less than creation time + max TTL." + }, + DELETE: { + projectSlug: "The slug of the project of the dynamic secret in.", + environmentSlug: "The slug of the environment of the dynamic secret in.", + path: "The path of the dynamic secret in.", + leaseId: "The ID of the dynamic secret lease.", + isForced: + "A boolean flag to delete the the dynamic secret from infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally." + } +} as const; export const SECRET_TAGS = { LIST: { projectId: "The ID of the project to list tags from." diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 6b7c02f6b..93c39a06f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -18,6 +18,7 @@ const envSchema = z DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` ), + MAX_LEASE_LIMIT: z.coerce.number().default(10000), DB_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()), DB_HOST: zpStr(z.string().describe("Postgres database host").optional()), DB_PORT: zpStr(z.string().describe("Postgres database port").optional()).default("5432"), 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/lib/types/index.ts b/backend/src/lib/types/index.ts index 7a34222a6..7d26a5e5d 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -13,7 +13,7 @@ export type TProjectPermission = { actorId: string; projectId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }; export type RequiredKeys = { 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/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index dceb31c03..4c0683797 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -16,7 +16,7 @@ export type TAuthMode = userId: string; tokenVersionId: string; // the session id of token used user: TUsers; - orgId?: string; + orgId: string; authMethod: AuthMethod; } | { @@ -119,7 +119,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { userId: user.id, tokenVersionId, actor, - orgId, + orgId: orgId as string, authMethod: token.authMethod }; break; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c452bfbd3..aea5baa94 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,34 @@ export const registerRoutes = async ( licenseService }); + const dynamicSecretProviders = buildDynamicSecretProviders(); + const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ + queueService, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL + }); + const dynamicSecretService = dynamicSecretServiceFactory({ + projectDAL, + dynamicSecretQueueService, + dynamicSecretDAL, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + folderDAL, + permissionService, + licenseService + }); + const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ + projectDAL, + permissionService, + dynamicSecretQueueService, + dynamicSecretDAL, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + folderDAL, + licenseService + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -591,6 +627,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/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 03e48c247..eaae4149c 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -1,6 +1,11 @@ import { z } from "zod"; -import { IntegrationAuthsSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; +import { + DynamicSecretsSchema, + IntegrationAuthsSchema, + SecretApprovalPoliciesSchema, + UsersSchema +} from "@app/db/schemas"; // sometimes the return data must be santizied to avoid leaking important values // always prefer pick over omit in zod @@ -56,3 +61,11 @@ export const secretRawSchema = z.object({ secretValue: z.string(), secretComment: z.string().optional() }); + +export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ + inputIV: true, + inputTag: true, + inputCiphertext: true, + keyEncoding: true, + algorithm: true +}); 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..b4f88e2ba --- /dev/null +++ b/backend/src/server/routes/v1/dynamic-secret-lease-router.ts @@ -0,0 +1,185 @@ +import ms from "ms"; +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; +import { daysToMillisecond } from "@app/lib/dates"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { SanitizedDynamicSecretSchema } from "../sanitizedSchemas"; + +export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(), + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug), + ttl: z + .string() + .optional() + .describe(DYNAMIC_SECRET_LEASES.CREATE.ttl) + .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" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path) + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema, + dynamicSecret: SanitizedDynamicSecretSchema, + data: z.unknown() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.dynamicSecretName, + ...req.body + }); + return { lease, data, dynamicSecret }; + } + }); + + server.route({ + url: "/:leaseId", + method: "DELETE", + schema: { + params: z.object({ + leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.leaseId) + }), + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.projectSlug), + path: z + .string() + .min(1) + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRET_LEASES.DELETE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.environmentSlug), + isForced: z.boolean().default(false).describe(DYNAMIC_SECRET_LEASES.DELETE.isForced) + }), + 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().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.leaseId) + }), + body: z.object({ + ttl: z + .string() + .describe(DYNAMIC_SECRET_LEASES.RENEW.ttl) + .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" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.projectSlug), + path: z + .string() + .min(1) + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRET_LEASES.RENEW.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.ttl) + }), + 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: "/:leaseId", + method: "GET", + schema: { + params: z.object({ + leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.leaseId) + }), + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.projectSlug), + path: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.environmentSlug) + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema.extend({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const lease = await server.services.dynamicSecretLease.getLeaseDetails({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + leaseId: req.params.leaseId, + ...req.query + }); + return { lease }; + } + }); +}; 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..85d8e8596 --- /dev/null +++ b/backend/src/server/routes/v1/dynamic-secret-router.ts @@ -0,0 +1,272 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { DYNAMIC_SECRETS } from "@app/lib/api-docs"; +import { daysToMillisecond } from "@app/lib/dates"; +import { removeTrailingSlash } from "@app/lib/fn"; +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"; + +import { SanitizedDynamicSecretSchema } from "../sanitizedSchemas"; + +export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.CREATE.projectSlug), + provider: DynamicSecretProviderSchema.describe(DYNAMIC_SECRETS.CREATE.provider), + defaultTTL: z + .string() + .describe(DYNAMIC_SECRETS.CREATE.defaultTTL) + .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" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .describe(DYNAMIC_SECRETS.CREATE.maxTTL) + .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" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }) + .nullable(), + path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), + environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), + name: z + .string() + .describe(DYNAMIC_SECRETS.CREATE.name) + .min(1) + .toLowerCase() + .max(64) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + } + }, + 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: "/:name", + method: "PATCH", + schema: { + params: z.object({ + name: z.string().toLowerCase().describe(DYNAMIC_SECRETS.UPDATE.name) + }), + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.UPDATE.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.UPDATE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.UPDATE.environmentSlug), + data: z.object({ + inputs: z.any().optional().describe(DYNAMIC_SECRETS.UPDATE.inputs), + defaultTTL: z + .string() + .describe(DYNAMIC_SECRETS.UPDATE.defaultTTL) + .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" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .describe(DYNAMIC_SECRETS.UPDATE.maxTTL) + .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" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }) + .nullable(), + newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional() + }) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.updateByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + path: req.body.path, + projectSlug: req.body.projectSlug, + environmentSlug: req.body.environmentSlug, + ...req.body.data + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/:name", + method: "DELETE", + schema: { + params: z.object({ + name: z.string().toLowerCase().describe(DYNAMIC_SECRETS.DELETE.name) + }), + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.DELETE.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.DELETE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.DELETE.environmentSlug), + isForced: z.boolean().default(false).describe(DYNAMIC_SECRETS.DELETE.isForced) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.deleteByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + ...req.body + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/:name", + method: "GET", + schema: { + params: z.object({ + name: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.name) + }), + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.GET_BY_NAME.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.environmentSlug) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema.extend({ + inputs: z.unknown() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.getDetails({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + ...req.query + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.LIST.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST.environmentSlug) + }), + response: { + 200: z.object({ + dynamicSecrets: SanitizedDynamicSecretSchema.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 }; + } + }); + + server.route({ + url: "/:name/leases", + method: "GET", + schema: { + params: z.object({ + name: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.name) + }), + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.projectSlug), + path: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.environmentSlug) + }), + 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, + name: req.params.name, + ...req.query + }); + return { leases }; + } + }); +}; 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" }); 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..810628030 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts @@ -0,0 +1,80 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { DynamicSecretLeasesSchema, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TDynamicSecretLeaseDALFactory = ReturnType; + +export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecretLease); + + const countLeasesForDynamicSecret = async (dynamicSecretId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.DynamicSecretLease).count("*").where({ dynamicSecretId }).first(); + return parseInt(doc || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "DynamicSecretCountLeases" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.DynamicSecretLease) + .where({ [`${TableName.DynamicSecretLease}.id` as "id"]: id }) + .first() + .join( + TableName.DynamicSecret, + `${TableName.DynamicSecretLease}.dynamicSecretId`, + `${TableName.DynamicSecret}.id` + ) + .select(selectAllTableCols(TableName.DynamicSecretLease)) + .select( + db.ref("id").withSchema(TableName.DynamicSecret).as("dynId"), + db.ref("name").withSchema(TableName.DynamicSecret).as("dynName"), + db.ref("version").withSchema(TableName.DynamicSecret).as("dynVersion"), + db.ref("type").withSchema(TableName.DynamicSecret).as("dynType"), + db.ref("defaultTTL").withSchema(TableName.DynamicSecret).as("dynDefaultTTL"), + db.ref("maxTTL").withSchema(TableName.DynamicSecret).as("dynMaxTTL"), + db.ref("inputIV").withSchema(TableName.DynamicSecret).as("dynInputIV"), + db.ref("inputTag").withSchema(TableName.DynamicSecret).as("dynInputTag"), + db.ref("inputCiphertext").withSchema(TableName.DynamicSecret).as("dynInputCiphertext"), + db.ref("algorithm").withSchema(TableName.DynamicSecret).as("dynAlgorithm"), + db.ref("keyEncoding").withSchema(TableName.DynamicSecret).as("dynKeyEncoding"), + db.ref("folderId").withSchema(TableName.DynamicSecret).as("dynFolderId"), + db.ref("status").withSchema(TableName.DynamicSecret).as("dynStatus"), + db.ref("statusDetails").withSchema(TableName.DynamicSecret).as("dynStatusDetails"), + db.ref("createdAt").withSchema(TableName.DynamicSecret).as("dynCreatedAt"), + db.ref("updatedAt").withSchema(TableName.DynamicSecret).as("dynUpdatedAt") + ); + if (!doc) return; + + return { + ...DynamicSecretLeasesSchema.parse(doc), + dynamicSecret: { + id: doc.dynId, + name: doc.dynName, + version: doc.dynVersion, + type: doc.dynType, + defaultTTL: doc.dynDefaultTTL, + maxTTL: doc.dynMaxTTL, + inputIV: doc.dynInputIV, + inputTag: doc.dynInputTag, + inputCiphertext: doc.dynInputCiphertext, + algorithm: doc.dynAlgorithm, + keyEncoding: doc.dynKeyEncoding, + folderId: doc.dynFolderId, + status: doc.dynStatus, + statusDetails: doc.dynStatusDetails, + createdAt: doc.dynCreatedAt, + updatedAt: doc.dynUpdatedAt + } + }; + } catch (error) { + throw new DatabaseError({ error, name: "DynamicSecretLeaseFindById" }); + } + }; + + return { ...orm, findById, countLeasesForDynamicSecret }; +}; 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..9bdb1c24e --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -0,0 +1,159 @@ +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 { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; +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, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const setLeaseRevocation = async (leaseId: string, expiry: number) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretRevocation, + { leaseId }, + { + jobId: leaseId, + backoff: { + type: "exponential", + delay: 3000 + }, + 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 = dynamicSecretLease.dynamicSecret; + 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.status as DynamicSecretStatus) !== DynamicSecretStatus.Deleting) + throw new DisableRotationErrors({ message: "Document not deleted" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfgId }); + if (dynamicSecretLeases.length) { + 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.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); + await Promise.all( + dynamicSecretLeases.map(({ externalEntityId }) => + selectedProvider.revoke(decryptedStoredInput, externalEntityId) + ) + ); + } + + await dynamicSecretDAL.deleteById(dynamicSecretCfgId); + } + logger.info("Finished dynamic secret job", job.id); + } catch (error) { + logger.error(error); + + if (job?.name === QueueJobs.DynamicSecretPruning) { + const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; + await dynamicSecretDAL.updateById(dynamicSecretCfgId, { + status: DynamicSecretStatus.FailedDeletion, + statusDetails: (error as Error)?.message?.slice(0, 255) + }); + } + + if (job?.name === QueueJobs.DynamicSecretRevocation) { + const { leaseId } = job.data as { leaseId: string }; + await dynamicSecretLeaseDAL.updateById(leaseId, { + status: DynamicSecretStatus.FailedDeletion, + statusDetails: (error as Error)?.message?.slice(0, 255) + }); + } + if (error instanceof DisableRotationErrors) { + if (job.id) { + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id); + } + } + // propogate to next part + throw error; + } + }); + + 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..ecddcd04a --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -0,0 +1,341 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import ms from "ms"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +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 { 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 { TProjectDALFactory } from "../project/project-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue"; +import { + DynamicSecretLeaseStatus, + TCreateDynamicSecretLeaseDTO, + TDeleteDynamicSecretLeaseDTO, + TDetailsDynamicSecretLeaseDTO, + TListDynamicSecretLeasesDTO, + TRenewDynamicSecretLeaseDTO +} from "./dynamic-secret-lease-types"; + +type TDynamicSecretLeaseServiceFactoryDep = { + dynamicSecretLeaseDAL: TDynamicSecretLeaseDALFactory; + dynamicSecretDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: TDynamicSecretLeaseQueueServiceFactory; + licenseService: Pick; + folderDAL: Pick; + permissionService: Pick; + projectDAL: Pick; +}; + +export type TDynamicSecretLeaseServiceFactory = ReturnType; + +export const dynamicSecretLeaseServiceFactory = ({ + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL, + folderDAL, + permissionService, + dynamicSecretQueueService, + projectDAL, + licenseService +}: TDynamicSecretLeaseServiceFactoryDep) => { + const create = async ({ + environmentSlug, + path, + name, + projectSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + ttl + }: TCreateDynamicSecretLeaseDTO) => { + const appCfg = getConfig(); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to create lease due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id); + if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT) + throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` }); + + 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, dynamicSecret: dynamicSecretCfg, data }; + }; + + const renewLease = async ({ + ttl, + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + path, + environmentSlug, + leaseId + }: TRenewDynamicSecretLeaseDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to renew lease due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, 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 = dynamicSecretLease.dynamicSecret; + 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(expireAt) - Number(new Date())); + const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + expireAt, + externalEntityId: entityId + }); + return updatedDynamicSecretLease; + }; + + const revokeLease = async ({ + leaseId, + environmentSlug, + path, + projectSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + isForced + }: TDeleteDynamicSecretLeaseDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, 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 = dynamicSecretLease.dynamicSecret; + 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 revokeResponse = await selectedProvider + .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId) + .catch(async (err) => { + // only propogate this error if forced is false + if (!isForced) return { error: err as Error }; + }); + + if ((revokeResponse as { error?: Error })?.error) { + const { error } = revokeResponse as { error?: Error }; + const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + status: DynamicSecretLeaseStatus.FailedDeletion, + statusDetails: error?.message?.slice(0, 255) + }); + return deletedDynamicSecretLease; + } + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return deletedDynamicSecretLease; + }; + + const listLeases = async ({ + path, + name, + actor, + actorId, + projectSlug, + actorOrgId, + environmentSlug, + actorAuthMethod + }: TListDynamicSecretLeasesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + return dynamicSecretLeases; + }; + + const getLeaseDetails = async ({ + projectSlug, + actorOrgId, + path, + environmentSlug, + actor, + actorId, + leaseId, + actorAuthMethod + }: TDetailsDynamicSecretLeaseDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, 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" }); + + return dynamicSecretLease; + }; + + return { + create, + listLeases, + revokeLease, + renewLease, + getLeaseDetails + }; +}; 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..bf182b349 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts @@ -0,0 +1,43 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum DynamicSecretLeaseStatus { + FailedDeletion = "Failed to delete" +} + +export type TCreateDynamicSecretLeaseDTO = { + name: string; + path: string; + environmentSlug: string; + ttl?: string; + projectSlug: string; +} & Omit; + +export type TDetailsDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; + +export type TListDynamicSecretLeasesDTO = { + name: string; + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; + +export type TDeleteDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environmentSlug: string; + projectSlug: string; + isForced?: boolean; +} & Omit; + +export type TRenewDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environmentSlug: string; + ttl?: string; + projectSlug: string; +} & Omit; 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..48d203ff2 --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-service.ts @@ -0,0 +1,341 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +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 { TProjectDALFactory } from "../project/project-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; +import { + DynamicSecretStatus, + TCreateDynamicSecretDTO, + TDeleteDynamicSecretDTO, + TDetailsDynamicSecretDTO, + TListDynamicSecretsDTO, + TUpdateDynamicSecretDTO +} from "./dynamic-secret-types"; +import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models"; + +type TDynamicSecretServiceFactoryDep = { + dynamicSecretDAL: TDynamicSecretDALFactory; + dynamicSecretLeaseDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: Pick< + TDynamicSecretLeaseQueueServiceFactory, + "pruneDynamicSecret" | "unsetLeaseRevocation" + >; + licenseService: Pick; + folderDAL: Pick; + projectDAL: Pick; + permissionService: Pick; +}; + +export type TDynamicSecretServiceFactory = ReturnType; + +export const dynamicSecretServiceFactory = ({ + dynamicSecretDAL, + dynamicSecretLeaseDAL, + licenseService, + folderDAL, + dynamicSecretProviders, + permissionService, + dynamicSecretQueueService, + projectDAL +}: TDynamicSecretServiceFactoryDep) => { + const create = async ({ + path, + actor, + name, + actorId, + maxTTL, + provider, + environmentSlug, + projectSlug, + actorOrgId, + defaultTTL, + actorAuthMethod + }: TCreateDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to create dynamic secret due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, 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 isConnected = await selectedProvider.validateConnection(provider.inputs); + if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); + + 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, + name + }); + return dynamicSecretCfg; + }; + + const updateByName = async ({ + name, + maxTTL, + defaultTTL, + inputs, + environmentSlug, + projectSlug, + path, + actor, + actorId, + newName, + actorOrgId, + actorAuthMethod + }: TUpdateDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to update dynamic secret due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + if (newName) { + const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, 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 isConnected = await selectedProvider.validateConnection(newInput); + if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); + + 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, + name: newName ?? name, + status: null, + statusDetails: null + }); + + return updatedDynamicCfg; + }; + + const deleteByName = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + name, + path, + environmentSlug, + isForced + }: TDeleteDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + // when not forced we check with the external system to first remove the things + // we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system + // this allows user to clean up it from infisical + if (isForced) { + // clear all queues for lease revocations + await Promise.all(leases.map(({ id: leaseId }) => dynamicSecretQueueService.unsetLeaseRevocation(leaseId))); + + const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id); + return deletedDynamicSecretCfg; + } + // 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, { + status: DynamicSecretStatus.Deleting + }); + await dynamicSecretQueueService.pruneDynamicSecret(updatedDynamicSecretCfg.id); + return updatedDynamicSecretCfg; + } + // if no leases just delete the config + const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id); + return deletedDynamicSecretCfg; + }; + + const getDetails = async ({ + name, + projectSlug, + path, + environmentSlug, + actorAuthMethod, + actorOrgId, + actorId, + actor + }: TDetailsDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; + return { ...dynamicSecretCfg, inputs: providerInputs }; + }; + + const list = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + path, + environmentSlug + }: TListDynamicSecretsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id }); + return dynamicSecretCfg; + }; + + return { + create, + updateByName, + deleteByName, + getDetails, + 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..02f2cbb86 --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-types.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { TProjectPermission } from "@app/lib/types"; + +import { DynamicSecretProviderSchema } from "./providers/models"; + +// various status for dynamic secret that happens in background +export enum DynamicSecretStatus { + Deleting = "Revocation in process", + FailedDeletion = "Failed to delete" +} + +type TProvider = z.infer; +export type TCreateDynamicSecretDTO = { + provider: TProvider; + defaultTTL: string; + maxTTL?: string | null; + path: string; + environmentSlug: string; + name: string; + projectSlug: string; +} & Omit; + +export type TUpdateDynamicSecretDTO = { + name: string; + newName?: string; + defaultTTL?: string; + maxTTL?: string | null; + path: string; + environmentSlug: string; + inputs?: TProvider["inputs"]; + projectSlug: string; +} & Omit; + +export type TDeleteDynamicSecretDTO = { + name: string; + path: string; + environmentSlug: string; + projectSlug: string; + isForced?: boolean; +} & Omit; + +export type TDetailsDynamicSecretDTO = { + name: string; + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; + +export type TListDynamicSecretsDTO = { + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; 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..2932fd924 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/models.ts @@ -0,0 +1,34 @@ +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 }>; + validateConnection: (inputs: unknown) => Promise; + 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..c0744031e --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/sql-database.ts @@ -0,0 +1,113 @@ +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, 48)(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 validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + const isConnected = await db + .raw("SELECT NOW()") + .then(() => true) + .catch(() => false); + await db.destroy(); + return isConnected; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = alphaNumericNanoId(32); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration + }); + + await db.raw(creationStatement.toString()); + await db.destroy(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: 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, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 059c93210..56fbe66f5 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -328,7 +328,7 @@ export const projectMembershipServiceFactory = ({ ); const hasCustomRole = Boolean(customInputRoles.length); if (hasCustomRole) { - const plan = await licenseService.getPlan(actorOrgId as string); + const plan = await licenseService.getPlan(actorOrgId); if (!plan?.rbac) throw new BadRequestError({ message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." diff --git a/cli/.gitignore b/cli/.gitignore index dcc148f21..5fa3e39c5 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,2 +1,3 @@ .infisical.json dist/ +agent-config.test.yaml diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 013e956cc..38d82a0a5 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -535,3 +535,23 @@ func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Reques return getRawSecretsV3Response, nil } + +func CallCreateDynamicSecretLeaseV1(httpClient *resty.Client, request CreateDynamicSecretLeaseV1Request) (CreateDynamicSecretLeaseV1Response, error) { + var createDynamicSecretLeaseResponse CreateDynamicSecretLeaseV1Response + response, err := httpClient. + R(). + SetResult(&createDynamicSecretLeaseResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/dynamic-secrets/leases", config.INFISICAL_URL)) + + if err != nil { + return CreateDynamicSecretLeaseV1Response{}, fmt.Errorf("CreateDynamicSecretLeaseV1: Unable to complete api request [err=%w]", err) + } + + if response.IsError() { + return CreateDynamicSecretLeaseV1Response{}, fmt.Errorf("CreateDynamicSecretLeaseV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + } + + return createDynamicSecretLeaseResponse, nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 982a58422..b49cb1581 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -500,6 +500,28 @@ type UniversalAuthRefreshResponse struct { AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` } +type CreateDynamicSecretLeaseV1Request struct { + Environment string `json:"environment"` + ProjectSlug string `json:"projectSlug"` + SecretPath string `json:"secretPath,omitempty"` + Slug string `json:"slug"` + TTL string `json:"ttl,omitempty"` +} + +type CreateDynamicSecretLeaseV1Response struct { + Lease struct { + Id string `json:"id"` + ExpireAt time.Time `json:"expireAt"` + } `json:"lease"` + DynamicSecret struct { + Id string `json:"id"` + DefaultTTL string `json:"defaultTTL"` + MaxTTL string `json:"maxTTL"` + Type string `json:"type"` + } `json:"dynamicSecret"` + Data map[string]interface{} `json:"data"` +} + type GetRawSecretsV3Request struct { Environment string `json:"environment"` WorkspaceId string `json:"workspaceId"` diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index db7c81225..ce3f7a8b0 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -14,6 +14,7 @@ import ( "os/signal" "path" "runtime" + "slices" "strings" "sync" "syscall" @@ -33,6 +34,9 @@ import ( const DEFAULT_INFISICAL_CLOUD_URL = "https://app.infisical.com" +// duration to reduce from expiry of dynamic leases so that it gets triggered before expiry +const DYNAMIC_SECRET_PRUNE_EXPIRE_BUFFER = -15 + type Config struct { Infisical InfisicalConfig `yaml:"infisical"` Auth AuthConfig `yaml:"auth"` @@ -84,6 +88,115 @@ type Template struct { } `yaml:"config"` } +func newAgentTemplateChannels(templates []Template) map[string]chan bool { + // we keep each destination as an identifier for various channel + templateChannel := make(map[string]chan bool) + for _, template := range templates { + templateChannel[template.DestinationPath] = make(chan bool) + } + return templateChannel +} + +type DynamicSecretLease struct { + LeaseID string + ExpireAt time.Time + Environment string + SecretPath string + Slug string + ProjectSlug string + Data map[string]interface{} + TemplateIDs []int +} + +type DynamicSecretLeaseManager struct { + leases []DynamicSecretLease + mutex sync.Mutex +} + +func (d *DynamicSecretLeaseManager) Prune() { + d.mutex.Lock() + defer d.mutex.Unlock() + + d.leases = slices.DeleteFunc(d.leases, func(s DynamicSecretLease) bool { + return time.Now().After(s.ExpireAt.Add(DYNAMIC_SECRET_PRUNE_EXPIRE_BUFFER * time.Second)) + }) +} + +func (d *DynamicSecretLeaseManager) Append(lease DynamicSecretLease) { + d.mutex.Lock() + defer d.mutex.Unlock() + + index := slices.IndexFunc(d.leases, func(s DynamicSecretLease) bool { + if lease.SecretPath == s.SecretPath && lease.Environment == s.Environment && lease.ProjectSlug == s.ProjectSlug && lease.Slug == s.Slug { + return true + } + return false + }) + + if index != -1 { + d.leases[index].TemplateIDs = append(d.leases[index].TemplateIDs, lease.TemplateIDs...) + return + } + d.leases = append(d.leases, lease) +} + +func (d *DynamicSecretLeaseManager) RegisterTemplate(projectSlug, environment, secretPath, slug string, templateId int) { + d.mutex.Lock() + defer d.mutex.Unlock() + + index := slices.IndexFunc(d.leases, func(lease DynamicSecretLease) bool { + if lease.SecretPath == secretPath && lease.Environment == environment && lease.ProjectSlug == projectSlug && lease.Slug == slug { + return true + } + return false + }) + + if index != -1 { + d.leases[index].TemplateIDs = append(d.leases[index].TemplateIDs, templateId) + } +} + +func (d *DynamicSecretLeaseManager) GetLease(projectSlug, environment, secretPath, slug string) *DynamicSecretLease { + d.mutex.Lock() + defer d.mutex.Unlock() + + for _, lease := range d.leases { + if lease.SecretPath == secretPath && lease.Environment == environment && lease.ProjectSlug == projectSlug && lease.Slug == slug { + return &lease + } + } + + return nil +} + +// for a given template find the first expiring lease +// The bool indicates whether it contains valid expiry list +func (d *DynamicSecretLeaseManager) GetFirstExpiringLeaseTime(templateId int) (time.Time, bool) { + d.mutex.Lock() + defer d.mutex.Unlock() + + if len(d.leases) == 0 { + return time.Time{}, false + } + + var firstExpiry time.Time + for i, el := range d.leases { + if i == 0 { + firstExpiry = el.ExpireAt + } + newLeaseTime := el.ExpireAt.Add(DYNAMIC_SECRET_PRUNE_EXPIRE_BUFFER * time.Second) + if newLeaseTime.Before(firstExpiry) { + firstExpiry = newLeaseTime + } + } + return firstExpiry, true +} + +func NewDynamicSecretLeaseManager(sigChan chan os.Signal) *DynamicSecretLeaseManager { + manager := &DynamicSecretLeaseManager{} + return manager +} + func ReadFile(filePath string) ([]byte, error) { return ioutil.ReadFile(filePath) } @@ -234,15 +347,43 @@ func secretTemplateFunction(accessToken string, existingEtag string, currentEtag } } -func ProcessTemplate(templatePath string, data interface{}, accessToken string, existingEtag string, currentEtag *string) (*bytes.Buffer, error) { +func dynamicSecretTemplateFunction(accessToken string, dynamicSecretManager *DynamicSecretLeaseManager, templateId int) func(...string) (map[string]interface{}, error) { + return func(args ...string) (map[string]interface{}, error) { + argLength := len(args) + if argLength != 4 && argLength != 5 { + return nil, fmt.Errorf("Invalid arguments found for dynamic-secret function. Check template %i", templateId) + } + + projectSlug, envSlug, secretPath, slug, ttl := args[0], args[1], args[2], args[3], "" + if argLength == 5 { + ttl = args[4] + } + dynamicSecretData := dynamicSecretManager.GetLease(projectSlug, envSlug, secretPath, slug) + if dynamicSecretData != nil { + dynamicSecretManager.RegisterTemplate(projectSlug, envSlug, secretPath, slug, templateId) + return dynamicSecretData.Data, nil + } + + res, err := util.CreateDynamicSecretLease(accessToken, projectSlug, envSlug, secretPath, slug, ttl) + if err != nil { + return nil, err + } + + dynamicSecretManager.Append(DynamicSecretLease{LeaseID: res.Lease.Id, ExpireAt: res.Lease.ExpireAt, Environment: envSlug, SecretPath: secretPath, Slug: slug, ProjectSlug: projectSlug, Data: res.Data, TemplateIDs: []int{templateId}}) + return res.Data, nil + } +} + +func ProcessTemplate(templateId int, templatePath string, data interface{}, accessToken string, existingEtag string, currentEtag *string, dynamicSecretManager *DynamicSecretLeaseManager) (*bytes.Buffer, error) { // custom template function to fetch secrets from Infisical secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) + dynamicSecretFunction := dynamicSecretTemplateFunction(accessToken, dynamicSecretManager, templateId) funcs := template.FuncMap{ - "secret": secretFunction, + "secret": secretFunction, + "dynamic_secret": dynamicSecretFunction, } templateName := path.Base(templatePath) - tmpl, err := template.New(templateName).Funcs(funcs).ParseFiles(templatePath) if err != nil { return nil, err @@ -256,7 +397,7 @@ func ProcessTemplate(templatePath string, data interface{}, accessToken string, return &buf, nil } -func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken string, existingEtag string, currentEtag *string) (*bytes.Buffer, error) { +func ProcessBase64Template(templateId int, encodedTemplate string, data interface{}, accessToken string, existingEtag string, currentEtag *string, dynamicSecretLeaser *DynamicSecretLeaseManager) (*bytes.Buffer, error) { // custom template function to fetch secrets from Infisical decoded, err := base64.StdEncoding.DecodeString(encodedTemplate) if err != nil { @@ -266,8 +407,10 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken templateString := string(decoded) secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) // TODO: Fix this + dynamicSecretFunction := dynamicSecretTemplateFunction(accessToken, dynamicSecretLeaser, templateId) funcs := template.FuncMap{ - "secret": secretFunction, + "secret": secretFunction, + "dynamic_secret": dynamicSecretFunction, } templateName := "base64Template" @@ -285,7 +428,7 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken return &buf, nil } -type TokenManager struct { +type AgentManager struct { accessToken string accessTokenTTL time.Duration accessTokenMaxTTL time.Duration @@ -294,6 +437,7 @@ type TokenManager struct { mutex sync.Mutex filePaths []Sink // Store file paths if needed templates []Template + dynamicSecretLeases *DynamicSecretLeaseManager clientIdPath string clientSecretPath string newAccessTokenNotificationChan chan bool @@ -302,8 +446,8 @@ type TokenManager struct { exitAfterAuth bool } -func NewTokenManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *TokenManager { - return &TokenManager{ +func NewAgentManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *AgentManager { + return &AgentManager{ filePaths: fileDeposits, templates: templates, clientIdPath: clientIdPath, @@ -315,7 +459,7 @@ func NewTokenManager(fileDeposits []Sink, templates []Template, clientIdPath str } -func (tm *TokenManager) SetToken(token string, accessTokenTTL time.Duration, accessTokenMaxTTL time.Duration) { +func (tm *AgentManager) SetToken(token string, accessTokenTTL time.Duration, accessTokenMaxTTL time.Duration) { tm.mutex.Lock() defer tm.mutex.Unlock() @@ -326,7 +470,7 @@ func (tm *TokenManager) SetToken(token string, accessTokenTTL time.Duration, acc tm.newAccessTokenNotificationChan <- true } -func (tm *TokenManager) GetToken() string { +func (tm *AgentManager) GetToken() string { tm.mutex.Lock() defer tm.mutex.Unlock() @@ -334,7 +478,7 @@ func (tm *TokenManager) GetToken() string { } // Fetches a new access token using client credentials -func (tm *TokenManager) FetchNewAccessToken() error { +func (tm *AgentManager) FetchNewAccessToken() error { clientID := os.Getenv("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID") if clientID == "" { clientIDAsByte, err := ReadFile(tm.clientIdPath) @@ -384,7 +528,7 @@ func (tm *TokenManager) FetchNewAccessToken() error { } // Refreshes the existing access token -func (tm *TokenManager) RefreshAccessToken() error { +func (tm *AgentManager) RefreshAccessToken() error { httpClient := resty.New() httpClient.SetRetryCount(10000). SetRetryMaxWaitTime(20 * time.Second). @@ -405,7 +549,7 @@ func (tm *TokenManager) RefreshAccessToken() error { return nil } -func (tm *TokenManager) ManageTokenLifecycle() { +func (tm *AgentManager) ManageTokenLifecycle() { for { accessTokenMaxTTLExpiresInTime := tm.accessTokenFetchedTime.Add(tm.accessTokenMaxTTL - (5 * time.Second)) accessTokenRefreshedTime := tm.accessTokenRefreshedTime @@ -473,7 +617,7 @@ func (tm *TokenManager) ManageTokenLifecycle() { } } -func (tm *TokenManager) WriteTokenToFiles() { +func (tm *AgentManager) WriteTokenToFiles() { token := tm.GetToken() for _, sinkFile := range tm.filePaths { if sinkFile.Type == "file" { @@ -490,7 +634,7 @@ func (tm *TokenManager) WriteTokenToFiles() { } } -func (tm *TokenManager) WriteTemplateToFile(bytes *bytes.Buffer, template *Template) { +func (tm *AgentManager) WriteTemplateToFile(bytes *bytes.Buffer, template *Template) { if err := WriteBytesToFile(bytes, template.DestinationPath); err != nil { log.Error().Msgf("template engine: unable to write secrets to path because %s. Will try again on next cycle", err) return @@ -498,7 +642,7 @@ func (tm *TokenManager) WriteTemplateToFile(bytes *bytes.Buffer, template *Templ log.Info().Msgf("template engine: secret template at path %s has been rendered and saved to path %s", template.SourcePath, template.DestinationPath) } -func (tm *TokenManager) MonitorSecretChanges(secretTemplate Template, sigChan chan os.Signal) { +func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId int, sigChan chan os.Signal) { pollingInterval := time.Duration(5 * time.Minute) @@ -523,47 +667,61 @@ func (tm *TokenManager) MonitorSecretChanges(secretTemplate Template, sigChan ch execCommand := secretTemplate.Config.Execute.Command for { - token := tm.GetToken() + select { + case <-sigChan: + return + default: + { + tm.dynamicSecretLeases.Prune() + token := tm.GetToken() + if token != "" { + var processedTemplate *bytes.Buffer + var err error - if token != "" { + if secretTemplate.SourcePath != "" { + processedTemplate, err = ProcessTemplate(templateId, secretTemplate.SourcePath, nil, token, existingEtag, ¤tEtag, tm.dynamicSecretLeases) + } else { + processedTemplate, err = ProcessBase64Template(templateId, secretTemplate.Base64TemplateContent, nil, token, existingEtag, ¤tEtag, tm.dynamicSecretLeases) + } - var processedTemplate *bytes.Buffer - var err error + if err != nil { + log.Error().Msgf("unable to process template because %v", err) + } else { + if (existingEtag != currentEtag) || firstRun { - if secretTemplate.SourcePath != "" { - processedTemplate, err = ProcessTemplate(secretTemplate.SourcePath, nil, token, existingEtag, ¤tEtag) - } else { - processedTemplate, err = ProcessBase64Template(secretTemplate.Base64TemplateContent, nil, token, existingEtag, ¤tEtag) - } + tm.WriteTemplateToFile(processedTemplate, &secretTemplate) + existingEtag = currentEtag - if err != nil { - log.Error().Msgf("unable to process template because %v", err) - } else { - if (existingEtag != currentEtag) || firstRun { + if !firstRun && execCommand != "" { + log.Info().Msgf("executing command: %s", execCommand) + err := ExecuteCommandWithTimeout(execCommand, execTimeout) - tm.WriteTemplateToFile(processedTemplate, &secretTemplate) - existingEtag = currentEtag + if err != nil { + log.Error().Msgf("unable to execute command because %v", err) + } - if !firstRun && execCommand != "" { - log.Info().Msgf("executing command: %s", execCommand) - err := ExecuteCommandWithTimeout(execCommand, execTimeout) - - if err != nil { - log.Error().Msgf("unable to execute command because %v", err) + } + if firstRun { + firstRun = false + } } + } + // now the idea is we pick the next sleep time in which the one shorter out of + // - polling time + // - first lease that's gonna get expired in the template + firstLeaseExpiry, isValid := tm.dynamicSecretLeases.GetFirstExpiringLeaseTime(templateId) + var waitTime = pollingInterval + if isValid && firstLeaseExpiry.Sub(time.Now()) < pollingInterval { + waitTime = firstLeaseExpiry.Sub(time.Now()) } - if firstRun { - firstRun = false - } + time.Sleep(waitTime) + } else { + // It fails to get the access token. So we will re-try in 3 seconds. We do this because if we don't, the user will have to wait for the next polling interval to get the first secret render. + time.Sleep(3 * time.Second) } } - time.Sleep(pollingInterval) - } else { - // It fails to get the access token. So we will re-try in 3 seconds. We do this because if we don't, the user will have to wait for the next polling interval to get the first secret render. - time.Sleep(3 * time.Second) } - } } @@ -645,13 +803,14 @@ var agentCmd = &cobra.Command{ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) filePaths := agentConfig.Sinks - tm := NewTokenManager(filePaths, agentConfig.Templates, configUniversalAuthType.ClientIDPath, configUniversalAuthType.ClientSecretPath, tokenRefreshNotifier, configUniversalAuthType.RemoveClientSecretOnRead, agentConfig.Infisical.ExitAfterAuth) + tm := NewAgentManager(filePaths, agentConfig.Templates, configUniversalAuthType.ClientIDPath, configUniversalAuthType.ClientSecretPath, tokenRefreshNotifier, configUniversalAuthType.RemoveClientSecretOnRead, agentConfig.Infisical.ExitAfterAuth) + tm.dynamicSecretLeases = NewDynamicSecretLeaseManager(sigChan) go tm.ManageTokenLifecycle() for i, template := range agentConfig.Templates { log.Info().Msgf("template engine started for template %v...", i+1) - go tm.MonitorSecretChanges(template, sigChan) + go tm.MonitorSecretChanges(template, i, sigChan) } for { diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 576e74909..4a7dc782a 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -1,5 +1,7 @@ package models +import "time" + type UserCredentials struct { Email string `json:"email"` PrivateKey string `json:"privateKey"` @@ -40,6 +42,23 @@ type PlaintextSecretResult struct { Etag string } +type DynamicSecret struct { + Id string `json:"id"` + DefaultTTL string `json:"defaultTTL"` + MaxTTL string `json:"maxTTL"` + Type string `json:"type"` +} + +type DynamicSecretLease struct { + Lease struct { + Id string `json:"id"` + ExpireAt time.Time `json:"expireAt"` + } `json:"lease"` + DynamicSecret DynamicSecret `json:"dynamicSecret"` + // this is a varying dict based on provider + Data map[string]interface{} `json:"data"` +} + type SingleFolder struct { ID string `json:"_id"` Name string `json:"name"` diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 8c142a4ec..6f56bc84a 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -195,6 +195,31 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin }, nil } +func CreateDynamicSecretLease(accessToken string, projectSlug string, environmentName string, secretsPath string, slug string, ttl string) (models.DynamicSecretLease, error) { + httpClient := resty.New() + httpClient.SetAuthToken(accessToken). + SetHeader("Accept", "application/json") + + dynamicSecretRequest := api.CreateDynamicSecretLeaseV1Request{ + ProjectSlug: projectSlug, + Environment: environmentName, + SecretPath: secretsPath, + Slug: slug, + TTL: ttl, + } + + dynamicSecret, err := api.CallCreateDynamicSecretLeaseV1(httpClient, dynamicSecretRequest) + if err != nil { + return models.DynamicSecretLease{}, err + } + + return models.DynamicSecretLease{ + Lease: dynamicSecret.Lease, + Data: dynamicSecret.Data, + DynamicSecret: dynamicSecret.DynamicSecret, + }, nil +} + func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedSecretV3) ([]models.SingleEnvironmentVariable, error) { if importedSecrets == nil { return secrets, nil 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/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/Accordion/Accordion.tsx b/frontend/src/components/v2/Accordion/Accordion.tsx index dc7edd05e..c37649393 100644 --- a/frontend/src/components/v2/Accordion/Accordion.tsx +++ b/frontend/src/components/v2/Accordion/Accordion.tsx @@ -27,7 +27,7 @@ export const AccordionTrigger = forwardRef< ( +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..5f41e38f5 --- /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, environmentSlug, projectSlug }) => { + queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); + } + }); +}; + +export const useUpdateDynamicSecret = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateDynamicSecretDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch<{ dynamicSecret: TDynamicSecret }>( + `/api/v1/dynamic-secrets/${dto.name}`, + dto + ); + return data.dynamicSecret; + }, + onSuccess: (_, { path, environmentSlug, projectSlug }) => { + queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); + } + }); +}; + +export const useDeleteDynamicSecret = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteDynamicSecretDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete<{ dynamicSecret: TDynamicSecret }>( + `/api/v1/dynamic-secrets/${dto.name}`, + { data: dto } + ); + return data.dynamicSecret; + }, + onSuccess: (_, { path, environmentSlug, projectSlug }) => { + queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); + } + }); +}; diff --git a/frontend/src/hooks/api/dynamicSecret/queries.ts b/frontend/src/hooks/api/dynamicSecret/queries.ts new file mode 100644 index 000000000..37f851bed --- /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: ({ + projectSlug, + environmentSlug, + path + }: Pick) => + [{ projectSlug, environmentSlug, path }, "dynamic-secrets"] as const, + details: ({ path, environmentSlug, projectSlug, name }: TDetailsDynamicSecretDTO) => + [{ projectSlug, path, environmentSlug, name }, "dynamic-secret-details"] as const +}; + +export const useGetDynamicSecrets = ({ projectSlug, environmentSlug, path }: TListDynamicSecretDTO) => { + return useQuery({ + queryKey: dynamicSecretKeys.list({ path, environmentSlug, projectSlug }), + enabled: Boolean(projectSlug && environmentSlug && path), + queryFn: async () => { + const { data } = await apiRequest.get<{ dynamicSecrets: TDynamicSecret[] }>( + "/api/v1/dynamic-secrets", + { + params: { + projectSlug, + environmentSlug, + path + } + } + ); + + return data.dynamicSecrets; + } + }); +}; + +export const useGetDynamicSecretDetails = ({ + projectSlug, + environmentSlug, + path, + name +}: TDetailsDynamicSecretDTO) => { + return useQuery({ + queryKey: dynamicSecretKeys.details({ path, environmentSlug, projectSlug, name }), + enabled: Boolean(projectSlug && environmentSlug && path && name), + queryFn: async () => { + const { data } = await apiRequest.get<{ + dynamicSecret: TDynamicSecret & { inputs: unknown }; + }>(`/api/v1/dynamic-secrets/${name}`, { + params: { + projectSlug, + environmentSlug, + 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..27d3a756c --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -0,0 +1,84 @@ +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; + name: 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 = { + projectSlug: string; + provider: TDynamicSecretProvider; + defaultTTL: string; + maxTTL?: string; + path: string; + environmentSlug: string; + name: string; +}; + +export type TUpdateDynamicSecretDTO = { + name: string; + projectSlug: string; + path: string; + environmentSlug: string; + data: { + newName?: string; + defaultTTL?: string; + maxTTL?: string | null; + inputs?: unknown; + }; +}; + +export type TListDynamicSecretDTO = { + projectSlug: string; + path: string; + environmentSlug: string; +}; + +export type TDeleteDynamicSecretDTO = { + projectSlug: string; + path: string; + environmentSlug: string; + name: string; + isForced?: boolean; +}; + +export type TDetailsDynamicSecretDTO = { + projectSlug: string; + path: string; + environmentSlug: string; + name: 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..3ed2b75d9 --- /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, environmentSlug, projectSlug, dynamicSecretName }) => { + queryClient.invalidateQueries( + dynamicSecretLeaseKeys.list({ path, projectSlug, environmentSlug, dynamicSecretName }) + ); + } + }); +}; + +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, environmentSlug, projectSlug, dynamicSecretName }) => { + queryClient.invalidateQueries( + dynamicSecretLeaseKeys.list({ path, projectSlug, environmentSlug, dynamicSecretName }) + ); + } + }); +}; + +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, environmentSlug, projectSlug, dynamicSecretName }) => { + queryClient.invalidateQueries( + dynamicSecretLeaseKeys.list({ path, projectSlug, environmentSlug, dynamicSecretName }) + ); + } + }); +}; diff --git a/frontend/src/hooks/api/dynamicSecretLease/queries.ts b/frontend/src/hooks/api/dynamicSecretLease/queries.ts new file mode 100644 index 000000000..9084098d1 --- /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: ({ projectSlug, environmentSlug, path, dynamicSecretName }: TListDynamicSecretLeaseDTO) => + [{ projectSlug, environmentSlug, path, dynamicSecretName }, "dynamic-secret-leases"] as const +}; + +export const useGetDynamicSecretLeases = ({ + projectSlug, + environmentSlug, + path, + dynamicSecretName, + enabled = true +}: TListDynamicSecretLeaseDTO) => { + return useQuery({ + queryKey: dynamicSecretLeaseKeys.list({ path, environmentSlug, projectSlug, dynamicSecretName }), + enabled: Boolean(projectSlug && environmentSlug && path && dynamicSecretName && enabled), + queryFn: async () => { + const { data } = await apiRequest.get<{ leases: TDynamicSecretLease[] }>( + `/api/v1/dynamic-secrets/${dynamicSecretName}/leases`, + { + params: { + projectSlug, + environmentSlug, + 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..76bedc8b3 --- /dev/null +++ b/frontend/src/hooks/api/dynamicSecretLease/types.ts @@ -0,0 +1,48 @@ +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 = { + dynamicSecretName: string; + projectSlug: string; + ttl?: string; + path: string; + environmentSlug: string; +}; + +export type TRenewDynamicSecretLeaseDTO = { + leaseId: string; + dynamicSecretName: string; + ttl?: string; + projectSlug: string; + path: string; + environmentSlug: string; +}; + +export type TListDynamicSecretLeaseDTO = { + dynamicSecretName: string; + projectSlug: string; + path: string; + environmentSlug: string; + enabled?: boolean; +}; + +export type TRevokeDynamicSecretLeaseDTO = { + leaseId: string; + dynamicSecretName: string; + projectSlug: string; + path: string; + environmentSlug: string; + isForced?: boolean; +}; 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/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 97b287084..978de8578 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -3,6 +3,7 @@ export type SubscriptionPlan = { membersUsed: number; memberLimit: number; auditLogs: boolean; + dynamicSecret: boolean; auditLogsRetentionDays: number; customAlerts: boolean; customRateLimits: boolean; @@ -21,14 +22,14 @@ export type SubscriptionPlan = { scim: boolean; ldap: boolean; status: - | "incomplete" - | "incomplete_expired" - | "trialing" - | "active" - | "past_due" - | "canceled" - | "unpaid" - | null; + | "incomplete" + | "incomplete_expired" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "unpaid" + | null; trial_end: number | null; has_used_trial: boolean; }; 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]; +}; 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..fcb077512 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"; @@ -67,6 +69,7 @@ export const SecretMainPage = () => { // env slug const environment = router.query.env as string; const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; const secretPath = (router.query.secretPath as string) || "/"; const canReadSecret = permission.can( ProjectPermissionActions.Read, @@ -103,12 +106,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 +138,13 @@ export const SecretMainPage = () => { enabled: canReadSecret } }); + + const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({ + projectSlug, + environmentSlug: environment, + path: secretPath + }); + // fech tags const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); @@ -163,7 +175,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 +227,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) { @@ -244,6 +259,7 @@ export const SecretMainPage = () => { importedSecrets={importedSecrets} environment={environment} workspaceId={workspaceId} + projectSlug={projectSlug} secretPath={secretPath} isVisible={isVisible} filter={filter} @@ -298,6 +314,15 @@ export const SecretMainPage = () => { secretPath={secretPath} sortDir={sortDir} /> + {canReadSecret && ( + + )} {canReadSecret && ( & { secrets: DecryptedSecret[] }>; environment: string; + // @depreciated will be moving all these details to zustand workspaceId: string; + projectSlug: string; secretPath?: string; filter: Filter; tags?: WsTag[]; @@ -79,6 +83,7 @@ export const ActionBar = ({ importedSecrets = [], environment, workspaceId, + projectSlug, secretPath = "/", filter, tags = [], @@ -93,6 +98,7 @@ export const ActionBar = ({ }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", + "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", "misc", @@ -311,7 +317,6 @@ export const ActionBar = ({ )} - handlePopUpToggle("misc", isOpen)} @@ -333,14 +338,14 @@ export const ActionBar = ({ > {(isAllowed) => ( + )} + + + {(isAllowed) => ( +