diff --git a/.env.example b/.env.example
index 8463fea92..bdb3e536d 100644
--- a/.env.example
+++ b/.env.example
@@ -3,9 +3,6 @@
# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION
ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218
-# Required
-DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
-
# JWT
# Required secrets to sign JWT tokens
# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION
@@ -16,6 +13,9 @@ POSTGRES_PASSWORD=infisical
POSTGRES_USER=infisical
POSTGRES_DB=infisical
+# Required
+DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+
# Redis
REDIS_URL=redis://redis:6379
diff --git a/.github/workflows/build-docker-image-to-prod.yml b/.github/workflows/build-docker-image-to-prod.yml
index d1ae80dad..3818fa1f8 100644
--- a/.github/workflows/build-docker-image-to-prod.yml
+++ b/.github/workflows/build-docker-image-to-prod.yml
@@ -41,6 +41,7 @@ jobs:
load: true
context: backend
tags: infisical/infisical:test
+ platforms: linux/amd64,linux/arm64
- name: โป Spawn backend container and dependencies
run: |
docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull
@@ -92,6 +93,7 @@ jobs:
project: 64mmf0n610
context: frontend
tags: infisical/frontend:test
+ platforms: linux/amd64,linux/arm64
build-args: |
POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml
new file mode 100644
index 000000000..78a193d30
--- /dev/null
+++ b/.github/workflows/build-staging-and-deploy-aws.yml
@@ -0,0 +1,140 @@
+name: Deployment pipeline
+on: [workflow_dispatch]
+
+permissions:
+ id-token: write
+ contents: read
+
+jobs:
+ infisical-image:
+ name: Build backend image
+ runs-on: ubuntu-latest
+ steps:
+ - name: โ๏ธ Checkout source
+ uses: actions/checkout@v3
+ - name: ๐ฆ Install dependencies to test all dependencies
+ run: npm ci --only-production
+ working-directory: backend
+ - name: Save commit hashes for tag
+ id: commit
+ uses: pr-mpt/actions-commit-hash@v2
+ - name: ๐ง Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+ - name: ๐ Login to Docker Hub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Set up Depot CLI
+ uses: depot/setup-action@v1
+ - name: ๐๏ธ Build backend and push to docker hub
+ uses: depot/build-push-action@v1
+ with:
+ project: 64mmf0n610
+ token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
+ push: true
+ context: .
+ file: Dockerfile.standalone-infisical
+ tags: |
+ infisical/staging_infisical:${{ steps.commit.outputs.short }}
+ infisical/staging_infisical:latest
+ platforms: linux/amd64,linux/arm64
+ build-args: |
+ POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
+ INFISICAL_PLATFORM_VERSION=${{ steps.commit.outputs.short }}
+
+ gamma-deployment:
+ name: Deploy to gamma
+ runs-on: ubuntu-latest
+ needs: [infisical-image]
+ environment:
+ name: Gamma
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Setup Node.js environment
+ uses: actions/setup-node@v2
+ with:
+ node-version: "20"
+ - name: Change directory to backend and install dependencies
+ env:
+ DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }}
+ run: |
+ cd backend
+ npm install
+ npm run migration:latest
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ audience: sts.amazonaws.com
+ aws-region: us-east-1
+ role-to-assume: arn:aws:iam::905418227878:role/deploy-new-ecs-img
+ - name: Save commit hashes for tag
+ id: commit
+ uses: pr-mpt/actions-commit-hash@v2
+ - name: Download task definition
+ run: |
+ aws ecs describe-task-definition --task-definition infisical-prod-platform --query taskDefinition > task-definition.json
+ - name: Render Amazon ECS task definition
+ id: render-web-container
+ uses: aws-actions/amazon-ecs-render-task-definition@v1
+ with:
+ task-definition: task-definition.json
+ container-name: infisical-prod-platform
+ image: infisical/staging_infisical:${{ steps.commit.outputs.short }}
+ environment-variables: "LOG_LEVEL=info"
+ - name: Deploy to Amazon ECS service
+ uses: aws-actions/amazon-ecs-deploy-task-definition@v1
+ with:
+ task-definition: ${{ steps.render-web-container.outputs.task-definition }}
+ service: infisical-prod-platform
+ cluster: infisical-prod-platform
+ wait-for-service-stability: true
+
+ production-postgres-deployment:
+ name: Deploy to production
+ runs-on: ubuntu-latest
+ needs: [gamma-deployment]
+ environment:
+ name: Production
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Setup Node.js environment
+ uses: actions/setup-node@v2
+ with:
+ node-version: "20"
+ - name: Change directory to backend and install dependencies
+ env:
+ DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }}
+ run: |
+ cd backend
+ npm install
+ npm run migration:latest
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ audience: sts.amazonaws.com
+ aws-region: us-east-1
+ role-to-assume: arn:aws:iam::381492033652:role/gha-make-prod-deployment
+ - name: Save commit hashes for tag
+ id: commit
+ uses: pr-mpt/actions-commit-hash@v2
+ - name: Download task definition
+ run: |
+ aws ecs describe-task-definition --task-definition infisical-prod-platform --query taskDefinition > task-definition.json
+ - name: Render Amazon ECS task definition
+ id: render-web-container
+ uses: aws-actions/amazon-ecs-render-task-definition@v1
+ with:
+ task-definition: task-definition.json
+ container-name: infisical-prod-platform
+ image: infisical/staging_infisical:${{ steps.commit.outputs.short }}
+ environment-variables: "LOG_LEVEL=info"
+ - name: Deploy to Amazon ECS service
+ uses: aws-actions/amazon-ecs-deploy-task-definition@v1
+ with:
+ task-definition: ${{ steps.render-web-container.outputs.task-definition }}
+ service: infisical-prod-platform
+ cluster: infisical-prod-platform
+ wait-for-service-stability: true
diff --git a/.github/workflows/build-staging-and-deploy.yml b/.github/workflows/build-staging-and-deploy.yml
deleted file mode 100644
index 31ffb8729..000000000
--- a/.github/workflows/build-staging-and-deploy.yml
+++ /dev/null
@@ -1,120 +0,0 @@
-name: Build, Publish and Deploy to Gamma
-on: [workflow_dispatch]
-
-jobs:
- infisical-image:
- name: Build backend image
- runs-on: ubuntu-latest
- steps:
- - name: โ๏ธ Checkout source
- uses: actions/checkout@v3
- - name: ๐ฆ Install dependencies to test all dependencies
- run: npm ci --only-production
- working-directory: backend
- # - name: ๐งช Run tests
- # run: npm run test:ci
- # working-directory: backend
- - name: Save commit hashes for tag
- id: commit
- uses: pr-mpt/actions-commit-hash@v2
- - name: ๐ง Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
- - name: ๐ Login to Docker Hub
- uses: docker/login-action@v2
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Set up Depot CLI
- uses: depot/setup-action@v1
- - name: ๐ฆ Build backend and export to Docker
- uses: depot/build-push-action@v1
- with:
- project: 64mmf0n610
- token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
- load: true
- context: .
- file: Dockerfile.standalone-infisical
- tags: infisical/infisical:test
- # - name: โป Spawn backend container and dependencies
- # run: |
- # docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull
- # - name: ๐งช Test backend image
- # run: |
- # ./.github/resources/healthcheck.sh infisical-backend-test
- # - name: โป Shut down backend container and dependencies
- # run: |
- # docker compose -f .github/resources/docker-compose.be-test.yml down
- - name: ๐๏ธ Build backend and push
- uses: depot/build-push-action@v1
- with:
- project: 64mmf0n610
- token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
- push: true
- context: .
- file: Dockerfile.standalone-infisical
- tags: |
- infisical/staging_infisical:${{ steps.commit.outputs.short }}
- infisical/staging_infisical:latest
- platforms: linux/amd64,linux/arm64
- build-args: |
- POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
- INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
- postgres-migration:
- name: Run latest migration files
- runs-on: ubuntu-latest
- needs: [infisical-image]
- steps:
- - name: Checkout code
- uses: actions/checkout@v2
- - name: Setup Node.js environment
- uses: actions/setup-node@v2
- with:
- node-version: "20"
- - name: Change directory to backend and install dependencies
- env:
- DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }}
- run: |
- cd backend
- npm install
- npm run migration:latest
- # - name: Run postgres DB migration files
- # env:
- # DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }}
- # run: npm run migration:latest
- gamma-deployment:
- name: Deploy to gamma
- runs-on: ubuntu-latest
- needs: [postgres-migration]
- steps:
- - name: โ๏ธ Checkout source
- uses: actions/checkout@v3
- - name: Install Helm
- uses: azure/setup-helm@v3
- with:
- version: v3.10.0
- - name: Install infisical helm chart
- run: |
- helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
- helm repo update
- - name: Install kubectl
- uses: azure/setup-kubectl@v3
- - name: Install doctl
- uses: digitalocean/action-doctl@v2
- with:
- token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- - name: Save DigitalOcean kubeconfig with short-lived credentials
- run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 infisical-gamma-postgres
- - name: switch to gamma namespace
- run: kubectl config set-context --current --namespace=gamma
- - name: test kubectl
- run: kubectl get ingress
- - name: Download helm values to file and upgrade gamma deploy
- run: |
- wget https://raw.githubusercontent.com/Infisical/infisical/main/.github/values.yaml
- helm upgrade infisical infisical-helm-charts/infisical-standalone --values values.yaml --wait --install
- if [[ $(helm status infisical) == *"FAILED"* ]]; then
- echo "Helm upgrade failed"
- exit 1
- else
- echo "Helm upgrade was successful"
- fi
diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical
index d4596115e..1ece89639 100644
--- a/Dockerfile.standalone-infisical
+++ b/Dockerfile.standalone-infisical
@@ -118,9 +118,6 @@ WORKDIR /backend
ENV TELEMETRY_ENABLED true
-HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \
- CMD node healthcheck.js
-
EXPOSE 8080
EXPOSE 443
diff --git a/README.md b/README.md
index 3c2fd5387..74e86ced3 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@
Infisical Cloud |
Self-Hosting |
Docs |
- Website
+ Website |
+ Hiring (Remote/SF)
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index de1df20be..801633ecc 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -4,6 +4,8 @@ import { TUsers } from "@app/db/schemas";
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { TGroupServiceFactory } from "@app/ee/services/group/group-service";
+import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
+import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -64,7 +66,7 @@ declare module "fastify" {
authMethod: ActorAuthMethod;
type: ActorType;
id: string;
- orgId?: string;
+ orgId: string;
};
// passport data
passportUser: {
@@ -121,6 +123,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 cf27ac9aa..99ac78932 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,
@@ -368,6 +374,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 0b8c9c9ff..c17974091 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 "./group-project-membership-roles";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index 6b7e514e1..ace4d5edb 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -63,6 +63,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/dynamic-secret-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts
new file mode 100644
index 000000000..c830e2a7f
--- /dev/null
+++ b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts
@@ -0,0 +1,184 @@
+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 { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+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/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts
new file mode 100644
index 000000000..0cbf8a312
--- /dev/null
+++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts
@@ -0,0 +1,271 @@
+import slugify from "@sindresorhus/slugify";
+import ms from "ms";
+import { z } from "zod";
+
+import { DynamicSecretLeasesSchema } from "@app/db/schemas";
+import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models";
+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 { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+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/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts
index 2f48129af..3f050f2c1 100644
--- a/backend/src/ee/routes/v1/index.ts
+++ b/backend/src/ee/routes/v1/index.ts
@@ -1,4 +1,6 @@
import { registerGroupRouter } from "./group-router";
+import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
+import { registerDynamicSecretRouter } from "./dynamic-secret-router";
import { registerLdapRouter } from "./ldap-router";
import { registerLicenseRouter } from "./license-router";
import { registerOrgRoleRouter } from "./org-role-router";
@@ -35,6 +37,15 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerSecretRotationProviderRouter, {
prefix: "/secret-rotation-providers"
});
+
+ await server.register(
+ async (dynamicSecretRouter) => {
+ await dynamicSecretRouter.register(registerDynamicSecretRouter);
+ await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" });
+ },
+ { prefix: "/dynamic-secrets" }
+ );
+
await server.register(registerSamlRouter, { prefix: "/sso" });
await server.register(registerScimRouter, { prefix: "/scim" });
await server.register(registerLdapRouter, { prefix: "/ldap" });
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/dynamic-secret-lease/dynamic-secret-lease-dal.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts
new file mode 100644
index 000000000..810628030
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts
new file mode 100644
index 000000000..9bdb1c24e
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts
new file mode 100644
index 000000000..e46c3c6ee
--- /dev/null
+++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts
@@ -0,0 +1,343 @@
+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 { logger } from "@app/lib/logger";
+import { TProjectDALFactory } from "@app/services/project/project-dal";
+import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
+
+import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
+import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models";
+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.Read,
+ 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 };
+ logger.error("Failed to revoke lease", { error: error?.message });
+ 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/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts
new file mode 100644
index 000000000..bf182b349
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts
new file mode 100644
index 000000000..0cc4aca2f
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts
new file mode 100644
index 000000000..1aef3cc86
--- /dev/null
+++ b/backend/src/ee/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 { TProjectDALFactory } from "@app/services/project/project-dal";
+import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
+
+import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal";
+import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue";
+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.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.find({ folderId: folder.id });
+ return dynamicSecretCfg;
+ };
+
+ return {
+ create,
+ updateByName,
+ deleteByName,
+ getDetails,
+ list
+ };
+};
diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts
new file mode 100644
index 000000000..02f2cbb86
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts
new file mode 100644
index 000000000..d66e60802
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts
new file mode 100644
index 000000000..2932fd924
--- /dev/null
+++ b/backend/src/ee/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/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts
new file mode 100644
index 000000000..7107fe3a3
--- /dev/null
+++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts
@@ -0,0 +1,123 @@
+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 (
+ // localhost
+ providerInputs.host === "localhost" ||
+ providerInputs.host === "127.0.0.1" ||
+ // database infisical uses
+ dbHost === providerInputs.host ||
+ // internal ips
+ providerInputs.host === "host.docker.internal" ||
+ providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) ||
+ providerInputs.host.match(/^192\.168\.\d+\.\d+/)
+ )
+ 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/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts
index 291e1d590..1097f06fb 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 ecc53a452..9ed8e5da9 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/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts
index 5a2e478e1..8eade1626 100644
--- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts
+++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts
@@ -90,7 +90,17 @@ export const secretRotationDbFn = async ({
const appCfg = getConfig();
const ssl = ca ? { rejectUnauthorized: false, ca } : undefined;
- if (host === "localhost" || host === "127.0.0.1" || getDbConnectionHost(appCfg.DB_CONNECTION_URI) === host)
+ const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI);
+ if (
+ host === "localhost" ||
+ host === "127.0.0.1" ||
+ // database infisical uses
+ dbHost === host ||
+ // internal ips
+ host === "host.docker.internal" ||
+ host.match(/^10\.\d+\.\d+\.\d+/) ||
+ host.match(/^192\.168\.\d+\.\d+/)
+ )
throw new Error("Invalid db host");
const db = knex({
diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index 2e42f3388..8736e80c6 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -194,6 +194,25 @@ export const FOLDERS = {
}
} as const;
+export const SECRETS = {
+ ATTACH_TAGS: {
+ secretName: "The name of the secret to attach tags to.",
+ secretPath: "The path of the secret to attach tags to.",
+ type: "The type of the secret to attach tags to. (shared/personal)",
+ environment: "The slug of the environment where the secret is located",
+ projectSlug: "The slug of the project where the secret is located",
+ tagSlugs: "An array of existing tag slugs to attach to the secret."
+ },
+ DETACH_TAGS: {
+ secretName: "The name of the secret to detach tags from.",
+ secretPath: "The path of the secret to detach tags from.",
+ type: "The type of the secret to attach tags to. (shared/personal)",
+ environment: "The slug of the environment where the secret is located",
+ projectSlug: "The slug of the project where the secret is located",
+ tagSlugs: "An array of existing tag slugs to detach from the secret."
+ }
+} as const;
+
export const RAW_SECRETS = {
LIST: {
workspaceId: "The ID of the project to list secrets from.",
@@ -285,3 +304,96 @@ export const AUDIT_LOGS = {
actor: "The actor to filter the audit logs by."
}
} 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."
+ },
+ CREATE: {
+ projectId: "The ID of the project to create the tag in.",
+ name: "The name of the tag to create.",
+ slug: "The slug of the tag to create.",
+ color: "The color of the tag to create."
+ },
+ DELETE: {
+ tagId: "The ID of the tag to delete.",
+ projectId: "The ID of the project to delete the tag from."
+ }
+} as const;
diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts
index 6b7c02f6b..4d3d55ffd 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"),
@@ -113,7 +114,8 @@ const envSchema = z
.enum(["true", "false"])
.transform((val) => val === "true")
.optional(),
- INFISICAL_CLOUD: zodStrBool.default("false")
+ INFISICAL_CLOUD: zodStrBool.default("false"),
+ MAINTENANCE_MODE: zodStrBool.default("false")
})
.transform((data) => ({
...data,
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/app.ts b/backend/src/server/app.ts
index 556a88d7c..51cef185a 100644
--- a/backend/src/server/app.ts
+++ b/backend/src/server/app.ts
@@ -24,6 +24,7 @@ import { fastifyErrHandler } from "./plugins/error-handler";
import { registerExternalNextjs } from "./plugins/external-nextjs";
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "./plugins/fastify-zod";
import { fastifyIp } from "./plugins/ip";
+import { maintenanceMode } from "./plugins/maintenanceMode";
import { fastifySwagger } from "./plugins/swagger";
import { registerRoutes } from "./routes";
@@ -72,6 +73,8 @@ export const main = async ({ db, smtp, logger, queue, keyStore }: TMain) => {
}
await server.register(helmet, { contentSecurityPolicy: false });
+ await server.register(maintenanceMode);
+
await server.register(registerRoutes, { smtp, queue, db, keyStore });
if (appCfg.isProductionMode) {
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/plugins/maintenanceMode.ts b/backend/src/server/plugins/maintenanceMode.ts
new file mode 100644
index 000000000..f40f1ff6d
--- /dev/null
+++ b/backend/src/server/plugins/maintenanceMode.ts
@@ -0,0 +1,12 @@
+import fp from "fastify-plugin";
+
+import { getConfig } from "@app/lib/config/env";
+
+export const maintenanceMode = fp(async (fastify) => {
+ fastify.addHook("onRequest", async (req) => {
+ const serverEnvs = getConfig();
+ if (req.url !== "/api/v1/auth/checkAuth" && req.method !== "GET" && serverEnvs.MAINTENANCE_MODE) {
+ throw new Error("Infisical is in maintenance mode. Please try again later.");
+ }
+ });
+});
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index 89f448d98..c0630ad48 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -8,6 +8,12 @@ import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-ser
import { groupDALFactory } from "@app/ee/services/group/group-dal";
import { groupServiceFactory } from "@app/ee/services/group/group-service";
import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
+import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal";
+import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
+import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers";
+import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal";
+import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue";
+import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal";
import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { licenseDALFactory } from "@app/ee/services/license/license-dal";
@@ -206,6 +212,8 @@ export const registerRoutes = async (
const userGroupMembershipDAL = userGroupMembershipDALFactory(db);
const secretScanningDAL = secretScanningDALFactory(db);
const licenseDAL = licenseDALFactory(db);
+ const dynamicSecretDAL = dynamicSecretDALFactory(db);
+ const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db);
const permissionService = permissionServiceFactory({
permissionDAL,
@@ -585,6 +593,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
@@ -628,6 +664,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/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts
index 1a048e945..730ba6cae 100644
--- a/backend/src/server/routes/v1/admin-router.ts
+++ b/backend/src/server/routes/v1/admin-router.ts
@@ -16,13 +16,16 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
schema: {
response: {
200: z.object({
- config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true })
+ config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).merge(
+ z.object({ isMigrationModeOn: z.boolean() })
+ )
})
}
},
handler: async () => {
const config = await getServerCfg();
- return { config };
+ const serverEnvs = getConfig();
+ return { config: { ...config, isMigrationModeOn: serverEnvs.MAINTENANCE_MODE } };
}
});
diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts
index c60f2b9ba..519b257ae 100644
--- a/backend/src/server/routes/v1/secret-tag-router.ts
+++ b/backend/src/server/routes/v1/secret-tag-router.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { SecretTagsSchema } from "@app/db/schemas";
+import { SECRET_TAGS } from "@app/lib/api-docs";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -10,7 +11,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => {
method: "GET",
schema: {
params: z.object({
- projectId: z.string().trim()
+ projectId: z.string().trim().describe(SECRET_TAGS.LIST.projectId)
}),
response: {
200: z.object({
@@ -36,12 +37,12 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => {
method: "POST",
schema: {
params: z.object({
- projectId: z.string().trim()
+ projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId)
}),
body: z.object({
- name: z.string().trim(),
- slug: z.string().trim(),
- color: z.string()
+ name: z.string().trim().describe(SECRET_TAGS.CREATE.name),
+ slug: z.string().trim().describe(SECRET_TAGS.CREATE.slug),
+ color: z.string().trim().describe(SECRET_TAGS.CREATE.color)
}),
response: {
200: z.object({
@@ -68,8 +69,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => {
method: "DELETE",
schema: {
params: z.object({
- projectId: z.string().trim(),
- tagId: z.string().trim()
+ projectId: z.string().trim().describe(SECRET_TAGS.DELETE.projectId),
+ tagId: z.string().trim().describe(SECRET_TAGS.DELETE.tagId)
}),
response: {
200: z.object({
diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts
index 1e224aa3b..f69466328 100644
--- a/backend/src/server/routes/v3/secret-router.ts
+++ b/backend/src/server/routes/v3/secret-router.ts
@@ -10,7 +10,7 @@ import {
} from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types";
-import { RAW_SECRETS } from "@app/lib/api-docs";
+import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
@@ -23,6 +23,124 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
import { secretRawSchema } from "../sanitizedSchemas";
export const registerSecretRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ url: "/tags/:secretName",
+ method: "POST",
+ schema: {
+ description: "Attach tags to a secret",
+ security: [
+ {
+ bearerAuth: []
+ }
+ ],
+ params: z.object({
+ secretName: z.string().trim().describe(SECRETS.ATTACH_TAGS.secretName)
+ }),
+ body: z.object({
+ projectSlug: z.string().trim().describe(SECRETS.ATTACH_TAGS.projectSlug),
+ environment: z.string().trim().describe(SECRETS.ATTACH_TAGS.environment),
+ secretPath: z
+ .string()
+ .trim()
+ .default("/")
+ .transform(removeTrailingSlash)
+ .describe(SECRETS.ATTACH_TAGS.secretPath),
+ type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(SECRETS.ATTACH_TAGS.type),
+ tagSlugs: z.string().array().min(1).describe(SECRETS.ATTACH_TAGS.tagSlugs)
+ }),
+ response: {
+ 200: z.object({
+ secret: SecretsSchema.omit({ secretBlindIndex: true }).merge(
+ z.object({
+ tags: SecretTagsSchema.pick({
+ id: true,
+ slug: true,
+ name: true,
+ color: true
+ }).array()
+ })
+ )
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const secret = await server.services.secret.attachTags({
+ secretName: req.params.secretName,
+ tagSlugs: req.body.tagSlugs,
+ path: req.body.secretPath,
+ environment: req.body.environment,
+ type: req.body.type,
+ projectSlug: req.body.projectSlug,
+ actorId: req.permission.id,
+ actor: req.permission.type,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ return { secret };
+ }
+ });
+
+ server.route({
+ url: "/tags/:secretName",
+ method: "DELETE",
+ schema: {
+ description: "Detach tags from a secret",
+ security: [
+ {
+ bearerAuth: []
+ }
+ ],
+ params: z.object({
+ secretName: z.string().trim().describe(SECRETS.DETACH_TAGS.secretName)
+ }),
+ body: z.object({
+ projectSlug: z.string().trim().describe(SECRETS.DETACH_TAGS.projectSlug),
+ environment: z.string().trim().describe(SECRETS.DETACH_TAGS.environment),
+ secretPath: z
+ .string()
+ .trim()
+ .default("/")
+ .transform(removeTrailingSlash)
+ .describe(SECRETS.DETACH_TAGS.secretPath),
+ type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(SECRETS.DETACH_TAGS.type),
+ tagSlugs: z.string().array().min(1).describe(SECRETS.DETACH_TAGS.tagSlugs)
+ }),
+ response: {
+ 200: z.object({
+ secret: SecretsSchema.omit({ secretBlindIndex: true }).merge(
+ z.object({
+ tags: SecretTagsSchema.pick({
+ id: true,
+ slug: true,
+ name: true,
+ color: true
+ }).array()
+ })
+ )
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const secret = await server.services.secret.detachTags({
+ secretName: req.params.secretName,
+ tagSlugs: req.body.tagSlugs,
+ path: req.body.secretPath,
+ environment: req.body.environment,
+ type: req.body.type,
+ projectSlug: req.body.projectSlug,
+ actorId: req.permission.id,
+ actor: req.permission.type,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ return { secret };
+ }
+ });
+
server.route({
url: "/raw",
method: "GET",
diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts
index 163a3e23e..74584910a 100644
--- a/backend/src/services/project-membership/project-membership-service.ts
+++ b/backend/src/services/project-membership/project-membership-service.ts
@@ -346,7 +346,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/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts
index 09e99eca6..99e9cadd8 100644
--- a/backend/src/services/project/project-dal.ts
+++ b/backend/src/services/project/project-dal.ts
@@ -193,8 +193,12 @@ export const projectDALFactory = (db: TDbClient) => {
}
};
- const findProjectBySlug = async (slug: string, orgId: string) => {
+ const findProjectBySlug = async (slug: string, orgId: string | undefined) => {
try {
+ if (!orgId) {
+ throw new BadRequestError({ message: "Organization ID is required when querying with slugs" });
+ }
+
const projects = await db(TableName.ProjectMembership)
.where(`${TableName.Project}.slug`, slug)
.where(`${TableName.Project}.orgId`, orgId)
diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts
index 5eaf081dc..2970d31bd 100644
--- a/backend/src/services/project/project-service.ts
+++ b/backend/src/services/project/project-service.ts
@@ -1,7 +1,7 @@
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
-import { ProjectMembershipRole, ProjectVersion } from "@app/db/schemas";
+import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -284,10 +284,11 @@ export const projectServiceFactory = ({
// Get the role permission for the identity
const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole(
- ProjectMembershipRole.Admin,
+ OrgMembershipRole.Member,
organization.id
);
+ // Identity has to be at least a member in order to create projects
const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPrivilege)
throw new ForbiddenRequestError({
diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts
index baa1484b1..c925d2587 100644
--- a/backend/src/services/secret-folder/secret-folder-service.ts
+++ b/backend/src/services/secret-folder/secret-folder-service.ts
@@ -232,6 +232,7 @@ export const secretFolderServiceFactory = ({
if (!parentFolder) return [];
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id });
+
return folders;
};
diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts
index 11cd522ca..504174765 100644
--- a/backend/src/services/secret/secret-dal.ts
+++ b/backend/src/services/secret/secret-dal.ts
@@ -150,6 +150,27 @@ export const secretDALFactory = (db: TDbClient) => {
}
};
+ const getSecretTags = async (secretId: string, tx?: Knex) => {
+ try {
+ const tags = await (tx || db)(TableName.JnSecretTag)
+ .join(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`)
+ .where({ [`${TableName.Secret}Id` as const]: secretId })
+ .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
+ .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
+ .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
+ .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName"));
+
+ return tags.map((el) => ({
+ id: el.tagId,
+ color: el.tagColor,
+ slug: el.tagSlug,
+ name: el.tagName
+ }));
+ } catch (error) {
+ throw new DatabaseError({ error, name: "get secret tags" });
+ }
+ };
+
const findByBlindIndexes = async (
folderId: string,
blindIndexes: Array<{ blindIndex: string; type: SecretType }>,
@@ -184,6 +205,7 @@ export const secretDALFactory = (db: TDbClient) => {
bulkUpdate,
deleteMany,
bulkUpdateNoVersionIncrement,
+ getSecretTags,
findByFolderId,
findByBlindIndexes
};
diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts
index f47428fc7..f8fed95bb 100644
--- a/backend/src/services/secret/secret-service.ts
+++ b/backend/src/services/secret/secret-service.ts
@@ -22,6 +22,7 @@ import { TSecretDALFactory } from "./secret-dal";
import { decryptSecretRaw, fnSecretBlindIndexCheck, fnSecretBulkInsert, fnSecretBulkUpdate } from "./secret-fns";
import { TSecretQueueFactory } from "./secret-queue";
import {
+ TAttachSecretTagsDTO,
TCreateBulkSecretDTO,
TCreateSecretDTO,
TCreateSecretRawDTO,
@@ -47,7 +48,7 @@ type TSecretServiceFactoryDep = {
secretTagDAL: TSecretTagDALFactory;
secretVersionDAL: TSecretVersionDALFactory;
folderDAL: Pick;
- projectDAL: Pick;
+ projectDAL: Pick;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
permissionService: Pick;
snapshotService: Pick;
@@ -307,6 +308,7 @@ export const secretServiceFactory = ({
if ((inputSecret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" });
const { secretName, ...el } = inputSecret;
+
const updatedSecret = await secretDAL.transaction(async (tx) =>
fnSecretBulkUpdate({
folderId,
@@ -442,6 +444,7 @@ export const secretServiceFactory = ({
const folderId = folder.id;
const secrets = await secretDAL.findByFolderId(folderId, actorId);
+
if (includeImports) {
const secretImports = await secretImportDAL.find({ folderId });
const allowedImports = secretImports.filter(({ importEnv, importPath }) =>
@@ -994,7 +997,209 @@ export const secretServiceFactory = ({
return secretVersions;
};
+ const attachTags = async ({
+ secretName,
+ tagSlugs,
+ path: secretPath,
+ environment,
+ type,
+ projectSlug,
+ actor,
+ actorAuthMethod,
+ actorOrgId,
+ actorId
+ }: TAttachSecretTagsDTO) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+
+ const { permission } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionActions.Edit,
+ subject(ProjectPermissionSub.Secrets, { environment, secretPath })
+ );
+
+ await projectDAL.checkProjectUpgradeStatus(project.id);
+
+ const secret = await getSecretByName({
+ actorId,
+ actor,
+ actorOrgId,
+ actorAuthMethod,
+ projectId: project.id,
+ environment,
+ path: secretPath,
+ secretName,
+ type
+ });
+
+ if (!secret) {
+ throw new BadRequestError({ message: "Secret not found" });
+ }
+ const folder = await folderDAL.findBySecretPath(project.id, environment, secretPath);
+
+ if (!folder) {
+ throw new BadRequestError({ message: "Folder not found" });
+ }
+
+ const tags = await secretTagDAL.find({
+ projectId: project.id,
+ $in: {
+ slug: tagSlugs
+ }
+ });
+
+ if (tags.length !== tagSlugs.length) {
+ throw new BadRequestError({ message: "One or more tags not found." });
+ }
+
+ const existingSecretTags = await secretDAL.getSecretTags(secret.id);
+
+ if (existingSecretTags.some((tag) => tagSlugs.includes(tag.slug))) {
+ throw new BadRequestError({ message: "One or more tags already exist on the secret" });
+ }
+
+ const combinedTags = new Set([...existingSecretTags.map((tag) => tag.id), ...tags.map((el) => el.id)]);
+
+ const updatedSecret = await secretDAL.transaction(async (tx) =>
+ fnSecretBulkUpdate({
+ folderId: folder.id,
+ projectId: project.id,
+ inputSecrets: [
+ {
+ filter: { id: secret.id },
+ data: {
+ tags: Array.from(combinedTags)
+ }
+ }
+ ],
+ secretDAL,
+ secretVersionDAL,
+ secretTagDAL,
+ secretVersionTagDAL,
+ tx
+ })
+ );
+
+ await snapshotService.performSnapshot(folder.id);
+ await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment });
+
+ return {
+ ...updatedSecret[0],
+ tags: [...existingSecretTags, ...tags].map((t) => ({ id: t.id, slug: t.slug, name: t.name, color: t.color }))
+ };
+ };
+
+ const detachTags = async ({
+ secretName,
+ tagSlugs,
+ path: secretPath,
+ environment,
+ type,
+ projectSlug,
+ actor,
+ actorAuthMethod,
+ actorOrgId,
+ actorId
+ }: TAttachSecretTagsDTO) => {
+ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
+
+ const { permission } = await permissionService.getProjectPermission(
+ actor,
+ actorId,
+ project.id,
+ actorAuthMethod,
+ actorOrgId
+ );
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionActions.Edit,
+ subject(ProjectPermissionSub.Secrets, { environment, secretPath })
+ );
+
+ await projectDAL.checkProjectUpgradeStatus(project.id);
+
+ const secret = await getSecretByName({
+ actorId,
+ actor,
+ actorOrgId,
+ actorAuthMethod,
+ projectId: project.id,
+ environment,
+ path: secretPath,
+ secretName,
+ type
+ });
+
+ if (!secret) {
+ throw new BadRequestError({ message: "Secret not found" });
+ }
+ const folder = await folderDAL.findBySecretPath(project.id, environment, secretPath);
+
+ if (!folder) {
+ throw new BadRequestError({ message: "Folder not found" });
+ }
+
+ const tags = await secretTagDAL.find({
+ projectId: project.id,
+ $in: {
+ slug: tagSlugs
+ }
+ });
+
+ if (tags.length !== tagSlugs.length) {
+ throw new BadRequestError({ message: "One or more tags not found." });
+ }
+
+ const existingSecretTags = await secretDAL.getSecretTags(secret.id);
+
+ // Make sure all the tags exist on the secret
+ const tagIdsToRemove = tags.map((tag) => tag.id);
+ const secretTagIds = existingSecretTags.map((tag) => tag.id);
+
+ if (!tagIdsToRemove.every((el) => secretTagIds.includes(el))) {
+ throw new BadRequestError({ message: "One or more tags not found on the secret" });
+ }
+
+ const newTags = existingSecretTags.filter((tag) => !tagIdsToRemove.includes(tag.id));
+
+ const updatedSecret = await secretDAL.transaction(async (tx) =>
+ fnSecretBulkUpdate({
+ folderId: folder.id,
+ projectId: project.id,
+ inputSecrets: [
+ {
+ filter: { id: secret.id },
+ data: {
+ tags: newTags.map((tag) => tag.id)
+ }
+ }
+ ],
+ secretDAL,
+ secretVersionDAL,
+ secretTagDAL,
+ secretVersionTagDAL,
+ tx
+ })
+ );
+
+ await snapshotService.performSnapshot(folder.id);
+ await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment });
+
+ return {
+ ...updatedSecret[0],
+ tags: newTags
+ };
+ };
+
return {
+ attachTags,
+ detachTags,
createSecret,
deleteSecret,
updateSecret,
diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts
index 7ad4d65d7..efd4f0f8b 100644
--- a/backend/src/services/secret/secret-types.ts
+++ b/backend/src/services/secret/secret-types.ts
@@ -206,6 +206,15 @@ export type TFnSecretBulkUpdate = {
tx?: Knex;
};
+export type TAttachSecretTagsDTO = {
+ projectSlug: string;
+ secretName: string;
+ tagSlugs: string[];
+ environment: string;
+ path: string;
+ type: SecretType;
+} & Omit;
+
export type TFnSecretBulkDelete = {
folderId: string;
projectId: string;
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 dfb0cf7bc..38d82a0a5 100644
--- a/cli/packages/api/api.go
+++ b/cli/packages/api/api.go
@@ -406,14 +406,14 @@ func CallDeleteSecretsV3(httpClient *resty.Client, request DeleteSecretV3Request
return nil
}
-func CallUpdateSecretsV3(httpClient *resty.Client, request UpdateSecretByNameV3Request) error {
+func CallUpdateSecretsV3(httpClient *resty.Client, request UpdateSecretByNameV3Request, secretName string) error {
var secretsResponse GetEncryptedSecretsV3Response
response, err := httpClient.
R().
SetResult(&secretsResponse).
SetHeader("User-Agent", USER_AGENT).
SetBody(request).
- Patch(fmt.Sprintf("%v/v3/secrets/%s", config.INFISICAL_URL, request.SecretName))
+ Patch(fmt.Sprintf("%v/v3/secrets/%s", config.INFISICAL_URL, secretName))
if err != nil {
return fmt.Errorf("CallUpdateSecretsV3: Unable to complete api request [err=%s]", err)
@@ -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 f80c17a92..b49cb1581 100644
--- a/cli/packages/api/model.go
+++ b/cli/packages/api/model.go
@@ -401,7 +401,6 @@ type DeleteSecretV3Request struct {
}
type UpdateSecretByNameV3Request struct {
- SecretName string `json:"secretName"`
WorkspaceID string `json:"workspaceId"`
Environment string `json:"environment"`
Type string `json:"type"`
@@ -501,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/cmd/secrets.go b/cli/packages/cmd/secrets.go
index ffe82179c..cf9c89b47 100644
--- a/cli/packages/cmd/secrets.go
+++ b/cli/packages/cmd/secrets.go
@@ -297,7 +297,6 @@ var secretsSetCmd = &cobra.Command{
updateSecretRequest := api.UpdateSecretByNameV3Request{
WorkspaceID: workspaceFile.WorkspaceId,
Environment: environmentName,
- SecretName: secret.PlainTextKey,
SecretValueCiphertext: secret.SecretValueCiphertext,
SecretValueIV: secret.SecretValueIV,
SecretValueTag: secret.SecretValueTag,
@@ -305,7 +304,7 @@ var secretsSetCmd = &cobra.Command{
SecretPath: secretsPath,
}
- err = api.CallUpdateSecretsV3(httpClient, updateSecretRequest)
+ err = api.CallUpdateSecretsV3(httpClient, updateSecretRequest, secret.PlainTextKey)
if err != nil {
util.HandleError(err, "Unable to process secret update request")
return
@@ -419,7 +418,7 @@ func getSecretsByNames(cmd *cobra.Command, args []string) {
util.HandleError(err, "Unable to parse path flag")
}
- secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}, "")
+ secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: true}, "")
if err != nil {
util.HandleError(err, "To fetch all secrets")
}
@@ -477,7 +476,7 @@ func generateExampleEnv(cmd *cobra.Command, args []string) {
util.HandleError(err, "Unable to parse flag")
}
- secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}, "")
+ secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: true}, "")
if err != nil {
util.HandleError(err, "To fetch all secrets")
}
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/docs/api-reference/endpoints/secret-tags/create.mdx b/docs/api-reference/endpoints/secret-tags/create.mdx
new file mode 100644
index 000000000..82d0eed17
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-tags/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v1/workspace/{projectId}/tags"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/secret-tags/delete.mdx b/docs/api-reference/endpoints/secret-tags/delete.mdx
new file mode 100644
index 000000000..cc98f03c2
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-tags/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v1/workspace/{projectId}/tags/{tagId}"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/secret-tags/list.mdx b/docs/api-reference/endpoints/secret-tags/list.mdx
new file mode 100644
index 000000000..c4a940f77
--- /dev/null
+++ b/docs/api-reference/endpoints/secret-tags/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v1/workspace/{projectId}/tags"
+---
\ No newline at end of file
diff --git a/docs/api-reference/endpoints/secrets/attach-tags.mdx b/docs/api-reference/endpoints/secrets/attach-tags.mdx
new file mode 100644
index 000000000..8dd0e6081
--- /dev/null
+++ b/docs/api-reference/endpoints/secrets/attach-tags.mdx
@@ -0,0 +1,4 @@
+---
+title: "Attach tags"
+openapi: "POST /api/v3/secrets/tags/{secretName}"
+---
diff --git a/docs/api-reference/endpoints/secrets/detach-tags.mdx b/docs/api-reference/endpoints/secrets/detach-tags.mdx
new file mode 100644
index 000000000..a74b1174e
--- /dev/null
+++ b/docs/api-reference/endpoints/secrets/detach-tags.mdx
@@ -0,0 +1,4 @@
+---
+title: "Detach tags"
+openapi: "DELETE /api/v3/secrets/tags/{secretName}"
+---
\ No newline at end of file
diff --git a/docs/documentation/platform/dynamic-secrets/overview.mdx b/docs/documentation/platform/dynamic-secrets/overview.mdx
new file mode 100644
index 000000000..42bc33223
--- /dev/null
+++ b/docs/documentation/platform/dynamic-secrets/overview.mdx
@@ -0,0 +1,30 @@
+---
+title: "Overview"
+description: "Learn how to generate secrets dynamically on-demand."
+---
+
+## Introduction
+
+Contrary to static key-value secrets, which require manual input of data into the secure Infisical storage, dynamic secrets are generated on-demand upon access.
+
+Dynamic secrets are unique to every identity using them. Such secrets come are generated only at the moment they are retrieved, eliminating the possibility of theft or reuse by another identity. Thanks to Infisical's integrated revocation capabilities, dynamic secrets can be promptly invalidated post-use, significantly reducing their lifespan.
+
+## Benefits of Dynamic Secrets
+
+This approach offers several advantages in terms of security and management:
+
+- **Enhanced Security**: By frequently changing secrets, dynamic secrets minimize the risk associated with secret compromise. Even if an attacker manages to obtain a secret, it would likely be invalid by the time they attempt to use it.
+
+- **Reduced Secret Lifetime**: The limited validity period of dynamic secrets means that they are less valuable targets for attackers. This inherently reduces the time window during which a secret can be exploited.
+
+- **Automated Management**: Dynamic secrets enable automated systems to handle the generation, distribution, revocation, and rotation of secrets without human intervention, thus reducing the risk of human error.
+
+- **Auditing and Traceability**: The generation of dynamic secrets can be tightly controlled and monitored. This allows for detailed auditing of who accessed what secret and when, improving overall security posture and compliance with regulatory standards.
+
+- **Scalability**: Dynamic secret management systems can scale more effectively to handle a large number of services and applications, as they automate much of the overhead associated with manual secret management.
+
+Dynamic secrets are particularly useful in environments with stringent security requirements, such as cloud environments, distributed systems, and microservices architectures, where they help to manage database credentials, API keys, service tokens, and other types of secrets.
+
+## Infisical Dynamic Secret Templates
+
+1. [PostgreSQL](./postgresql)
diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx
new file mode 100644
index 000000000..2c623a477
--- /dev/null
+++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx
@@ -0,0 +1,98 @@
+---
+title: "PostgreSQL"
+description: "Learn how to dynamically generate PostgreSQL Database user passwords."
+---
+
+The Infisical MySQL secret rotation allows you to automatically rotate your MySQL database user's password at a predefined interval.
+
+
+## Prerequisite
+
+1. Create a user with the required permission in your SQL instance.
+
+
+## Set up Dynamic Secrets with PostgreSQL
+
+
+
+ Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
+
+
+ 
+
+
+ 
+
+
+
+ Name by which you want the secret to be referenced
+
+
+
+ Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate)
+
+
+
+ Maximum time-to-live for a generated secret
+
+
+
+ Choose the service you want to generate dynamic secrets for
+
+
+
+ Database host
+
+
+
+ Database port
+
+
+
+ Username that will be used to create dynamic secrets
+
+
+
+ Password that will be used to create dynamic secrets
+
+
+
+ Name of the database for which you want to create dynamic secrets
+
+
+
+ A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions).
+
+
+ 
+
+
+
+ If you want to provide specific privileges for the future generated dynamic secrets, you are able to specify them as SQL statements.
+
+ 
+
+
+ After submitting the form, you will see a dynamic secret creates in the dashboard.
+
+
+ If this step fails, you might have to add the CA certficate.
+
+
+ 
+
+
+ Now that the dynamic secret is created, you can start generating unique secret values by specifying the Time-to-live within the predefined range.
+
+ 
+
+ After you click the `Submit` button, a new secret lease will be generated and the Database User and Database Password will be shown.
+
+ 
+
+
+ As soon as you have generated a few secret leases, you will be able to access them by clicking `Generate` on the dynamic secret row. In this modal, you are able to see the expiration time or delete a secret preemptively.
+
+ 
+
+
diff --git a/docs/documentation/platform/identities/overview.mdx b/docs/documentation/platform/identities/overview.mdx
index 7a4751487..4aae6fd08 100644
--- a/docs/documentation/platform/identities/overview.mdx
+++ b/docs/documentation/platform/identities/overview.mdx
@@ -4,7 +4,7 @@ description: "Programmatically interact with Infisical"
---
- Currently, identities can only be used to make authenticated requests to the Infisical API, SDKs, and Agent. They do not work with clients such as CLI, K8s Operator, Terraform Provider, etc.
+ Currently, identities can only be used to make authenticated requests to the Infisical API, SDKs, Terraform, K8s Operator, and Agent. They do not work with clients such as CLI, Ansible look up plugin, etc.
We will be releasing compatibility with it across clients in the coming quarter.
diff --git a/docs/documentation/platform/secret-rotation/mysql.mdx b/docs/documentation/platform/secret-rotation/mysql.mdx
index b630e349a..5bd658a0d 100644
--- a/docs/documentation/platform/secret-rotation/mysql.mdx
+++ b/docs/documentation/platform/secret-rotation/mysql.mdx
@@ -1,37 +1,102 @@
---
title: "MySQL/MariaDB"
-description: "Rotated database user password of a MySQL or MariaDB"
+description: "How to rotate MySQL/MariaDB database user passwords"
---
-Infisical will update periodically the provided database user's password.
+The Infisical MySQL secret rotation allows you to automatically rotate your MySQL database user's password at a predefined interval.
-
- At present Infisical do require access to your database. We will soon be released Infisical agent based rotation which would help you rotate without direct database access from Infisical cloud.
-
-## Working
+## Prerequisite
-1. User's has to create the two user's for Infisical to rotate and provide them required database access
-2. Infisical will connect with your database with admin access
-3. If last rotated one was username1, then username2 is chosen to be rotated
-5. Update it's password with random value
-6. After testing it gets saved to the provided secret mapping
+1. Create two users with the required permission in your MySQL instance. We'll refer to them as `user-a` and `user-b`.
+2. Create another MySQL user with just the permission to update the passwords of `user-a` and `user-b`. We'll refer to this user as the `admin` user.
+
+To learn more about MySQL permission system, please visit this [documentation](https://dev.mysql.com/doc/refman/8.0/en/privileges-provided.html).
+
+## How it works
+
+1. Infisical connects to your database using the provided `admin` user account.
+2. A random value is generated and the password for `user-a` is updated with the new value.
+3. The new password is then tested by logging into the database
+4. If test is success, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s).
+5. The process is then repeated for `user-b` on the next rotation.
+6. The cycle repeats until secret rotation is deleted/stopped.
## Rotation Configuration
-1. Head over to Secret Rotation configuration page of your project by clicking on side bar `Secret Rotation`
-2. Click on `MySQL`
-3. Provide the inputs
- - Admin Username: DB admin username
- - Admin Password: DB admin password
- - Host: DB host
- - Port: DB port(number)
- - Username1: The first username in two to rotate
- - Username2: The second username in two to rotate
- - CA: Certificate to connect with database(string)
-4. Final step
- - Select `Environment`, `Secret Path` and `Interval` to rotate the secrets
- - Finally select the secrets in your provided board to replace with new secret after each rotation
- - Your done and good to go.
+
+
+ Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar
+
+
+
+
+ Rotator admin username
+
-Congrats. You have 10x your MySQL/MariaDB access security.
+
+ Rotator admin password
+
+
+
+ Database host url
+
+
+
+ Database port number
+
+
+
+ The first username of two to rotate - `user-a`
+
+
+
+ The second username of two to rotate - `user-b`
+
+
+
+ Optional database certificate to connect with database
+
+
+
+
+ When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project.
+
+
+ The environment where the rotated credentials should be mapped to.
+
+
+
+ The secret path where the rotated credentials should be mapped to.
+
+
+
+ What interval should the credentials be rotated in days.
+
+
+
+ Select an existing secret key where the rotated database username value should be saved to.
+
+
+
+ Select an existing select key where the rotated database password value should be saved to.
+
+
+
+
+## FAQ
+
+
+
+ When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly.
+
+ This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out.
+
+ To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated.
+
+
+ The admin account is used by Infisical to update the credentials for `user-a` and `user-b`.
+
+ You don't need to grant all permission for your admin account but rather just the permissions to update both of the user's passwords.
+
+
diff --git a/docs/documentation/platform/secret-rotation/postgres.mdx b/docs/documentation/platform/secret-rotation/postgres.mdx
index b11ae1d76..1ddc7d558 100644
--- a/docs/documentation/platform/secret-rotation/postgres.mdx
+++ b/docs/documentation/platform/secret-rotation/postgres.mdx
@@ -1,33 +1,104 @@
---
title: "PostgreSQL/CockroachDB"
-description: "Rotated database user password of a PostgreSQL or Cockroach DB"
+description: "How to rotate postgreSQL/cockroach database user passwords"
---
-Infisical will update periodically the provided database user's password.
+The Infisical Postgres secret rotation allows you to automatically rotate your Postgres database user's password at a predefined interval.
-## Working
-1. User's has to create the two user's for Infisical to rotate and provide them required database access.
-2. Infisical will connect with your database with admin access.
-3. If last rotated one was username1, then username2 is chosen to be rotated.
-5. Update it's password with random value.
-6. After testing it gets saved to the provided secret mapping.
+## Prerequisite
+
+1. Create two users with the required permission in your PostgreSQL instance. We'll refer to them as `user-a` and `user-b`.
+2. Create another PostgreSQL user with just the permission to update the passwords of `user-a` and `user-b`. We'll refer to this user as the `admin` user.
+
+To learn more about Postgres permission system, please visit this [documentation](https://www.postgresql.org/docs/9.1/sql-grant.html).
+
+
+## How it works
+
+1. Infisical connects to your database using the provided `admin` user account.
+2. A random value is generated and the password for `user-a` is updated with the new value.
+3. The new password is then tested by logging into the database
+4. If test is success, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s).
+5. The process is then repeated for `user-b` on the next rotation.
+6. The cycle repeats until secret rotation is deleted/stopped.
## Rotation Configuration
-1. Head over to Secret Rotation configuration page of your project by clicking on side bar `Secret Rotation`
-2. Click on `PostgreSQL`
-3. Provide the inputs
- - Admin Username: DB admin username
- - Admin Password: DB admin password
- - Host: DB host
- - Port: DB port(number)
- - Username1: The first username in two to rotate
- - Username2: The second username in two to rotate
- - CA: Certificate to connect with database(string)
-4. Final step
- - Select `Environment`, `Secret Path` and `Interval` to rotate the secrets
- - Finally select the secrets in your provided board to replace with new secret after each rotation
- - Your done and good to go.
+
+
+ Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar
+
+
-Congratulations. You have improved your PostgreSQL/CockroachDB access security.
+
+
+ Rotator admin username
+
+
+
+ Rotator admin password
+
+
+
+ Database host url
+
+
+
+ Database port number
+
+
+
+ The first username of two to rotate - `user-a`
+
+
+
+ The second username of two to rotate - `user-b`
+
+
+
+ Optional database certificate to connect with database
+
+
+
+
+ When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project.
+
+
+ The environment where the rotated credentials should be mapped to.
+
+
+
+ The secret path where the rotated credentials should be mapped to.
+
+
+
+ What interval should the credentials be rotated in days.
+
+
+
+ Select an existing secret key where the rotated database username value should be saved to.
+
+
+
+ Select an existing select key where the rotated database password value should be saved to.
+
+
+
+
+## FAQ
+
+
+
+ When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly.
+
+ This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out.
+
+ To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated.
+
+
+ The admin account is used by Infisical to update the credentials for `user-a` and `user-b`.
+
+ You don't need to grant all permission for your admin account but rather just the permissions to update both of the user's passwords.
+
+
diff --git a/docs/documentation/platform/secret-rotation/sendgrid.mdx b/docs/documentation/platform/secret-rotation/sendgrid.mdx
index c4dd2797f..2a7b91a15 100644
--- a/docs/documentation/platform/secret-rotation/sendgrid.mdx
+++ b/docs/documentation/platform/secret-rotation/sendgrid.mdx
@@ -1,31 +1,58 @@
---
title: "Twilio SendGrid"
-description: "Rotate Twilio SendGrid API keys"
+description: "How to rotate Twilio SendGrid API keys"
---
-Twilio SendGrid is a cloud-based email delivery platform that helps businesses send transactional and marketing emails.
-It uses an API key to do various operations. Using Infisical you can easily dynamically change the keys.
+Eliminate the use of long lived secrets by rotating Twilio SendGrid API keys with Infisical.
-## Working
+## Prerequisite
-1. Infisical will need an admin token of SendGrid to create API keys dynamically.
-2. Using the given admin token and scope by user Infisical will create and rotate API keys periodically
-3. Under the hood infisical uses [SendGrid API](https://docs.sendgrid.com/api-reference/api-keys/create-api-keys)
+You will need a valid SendGrid admin key with the necessary scope to create additional API keys.
+
+Follow the [SendGrid Docs to create an admin api key](https://docs.sendgrid.com/ui/account-and-settings/api-keys)
+
+## How it works
+
+Using the provided admin API key, Infisical will attempt to create child API keys with the specified permissions.
+New keys will ge generated every time a rotation occurs. Behind the scenes, Infisical uses the [SendGrid API](https://docs.sendgrid.com/api-reference/api-keys/create-api-keys) to generate new API keys.
## Rotation Configuration
-1. Head over to Secret Rotation configuration page of your project by clicking on side bar `Secret Rotation`
-2. Click on `Twilio SendGrid Card`
-3. Provide the inputs
- - Admin API Key:
- SendGrid admin key to create lower scoped API keys.
- - API Key Scopes
- SendGrid generated API Key's scopes. For more info refer [this doc](https://docs.sendgrid.com/api-reference/api-key-permissions/api-key-permissions)
+
+
+ Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar
+
+
+
+
+ SendGrid admin API key with permission to create child scoped API keys.
+
-4. Final step
- - Select `Environment`, `Secret Path` and `Interval` to rotate the secrets
- - Finally select the secrets in your provided board to replace with new secret after each rotation
- - Your done and good to go.
-
-Now your output mapped secret value will be replaced periodically by SendGrid.
+
+ The permissions that the newly generated API keys will have. To view possible permissions, visit [this documentation](https://docs.sendgrid.com/api-reference/api-key-permissions/api-key-permissions).
+ Permissions must be entered as a list of strings.
+ Example: `["user.profile.read", "user.profile.update"]`
+
+
+
+ When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project.
+
+ The environment where the rotated credentials should be mapped to.
+
+
+
+ The secret path where the rotated credentials should be mapped to.
+
+
+
+ What interval should the credentials be rotated in days.
+
+
+
+ Select an existing select key where the newly rotated API key will get saved to.
+
+
+
+
+Now your output mapped secret value will be replaced periodically by SendGrid.
diff --git a/docs/images/agent/infisical-agent-diagram.png b/docs/images/agent/infisical-agent-diagram.png
index 27356ba11..5eab132f7 100644
Binary files a/docs/images/agent/infisical-agent-diagram.png and b/docs/images/agent/infisical-agent-diagram.png differ
diff --git a/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png
new file mode 100644
index 000000000..8d0fd3ecc
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png differ
diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal.png
new file mode 100644
index 000000000..5f487dd7f
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal.png differ
diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal.png
new file mode 100644
index 000000000..3cd6c06f6
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal.png differ
diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret.png b/docs/images/platform/dynamic-secrets/dynamic-secret.png
new file mode 100644
index 000000000..f08fc4c55
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret.png differ
diff --git a/docs/images/platform/dynamic-secrets/lease-data.png b/docs/images/platform/dynamic-secrets/lease-data.png
new file mode 100644
index 000000000..aecd8c11d
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-data.png differ
diff --git a/docs/images/platform/dynamic-secrets/lease-values.png b/docs/images/platform/dynamic-secrets/lease-values.png
new file mode 100644
index 000000000..d552845f8
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values.png differ
diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sql-statements.png
new file mode 100644
index 000000000..d0f3b09da
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-sql-statements.png differ
diff --git a/docs/images/platform/dynamic-secrets/provision-lease.png b/docs/images/platform/dynamic-secrets/provision-lease.png
new file mode 100644
index 000000000..f144a5ae2
Binary files /dev/null and b/docs/images/platform/dynamic-secrets/provision-lease.png differ
diff --git a/docs/images/secret-rotation/mysql-step1.png b/docs/images/secret-rotation/mysql-step1.png
new file mode 100644
index 000000000..316dd3adf
Binary files /dev/null and b/docs/images/secret-rotation/mysql-step1.png differ
diff --git a/docs/images/secret-rotation/postgres-step1.png b/docs/images/secret-rotation/postgres-step1.png
new file mode 100644
index 000000000..8b64932ea
Binary files /dev/null and b/docs/images/secret-rotation/postgres-step1.png differ
diff --git a/docs/images/secret-rotation/postgres-step2.png b/docs/images/secret-rotation/postgres-step2.png
new file mode 100644
index 000000000..b261e7464
Binary files /dev/null and b/docs/images/secret-rotation/postgres-step2.png differ
diff --git a/docs/images/secret-rotation/sendgrid-step1.png b/docs/images/secret-rotation/sendgrid-step1.png
new file mode 100644
index 000000000..cb919e34f
Binary files /dev/null and b/docs/images/secret-rotation/sendgrid-step1.png differ
diff --git a/docs/images/secret-rotation/sendgrid-step2.png b/docs/images/secret-rotation/sendgrid-step2.png
new file mode 100644
index 000000000..62c1f29ff
Binary files /dev/null and b/docs/images/secret-rotation/sendgrid-step2.png differ
diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx
index 7ddb616b2..29ca9c4ba 100644
--- a/docs/integrations/platforms/kubernetes.mdx
+++ b/docs/integrations/platforms/kubernetes.mdx
@@ -12,7 +12,7 @@ The operator continuously updates secrets and can also reload dependent deployme
## Install Operator
-The operator can be install via [Helm](helm.sh) or [kubectl](https://github.com/kubernetes/kubectl)
+The operator can be install via [Helm](https://helm.sh) or [kubectl](https://github.com/kubernetes/kubectl)
@@ -61,23 +61,38 @@ Once you have installed the operator to your cluster, you'll need to create a `I
apiVersion: secrets.infisical.com/v1alpha1
kind: InfisicalSecret
metadata:
- # Name of of this InfisicalSecret resource
- name: infisicalsecret-sample
+ name: infisicalsecret-sample
+ labels:
+ label-to-be-passed-to-managed-secret: sample-value
+ annotations:
+ example.com/annotation-to-be-passed-to-managed-secret: "sample-value"
spec:
- # The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used
- hostAPI: https://app.infisical.com/api
- resyncInterval: 60
- authentication:
- serviceToken:
- serviceTokenSecretReference:
- secretName: service-token
+ hostAPI: https://app.infisical.com/api
+ resyncInterval: 10
+ authentication:
+ # Make sure to only have 1 authentication method defined, serviceToken/universalAuth.
+ # If you have multiple authentication methods defined, it may cause issues.
+ universalAuth:
+ secretsScope:
+ projectSlug:
+ envSlug: # "dev", "staging", "prod", etc..
+ secretsPath: "" # Root is "/"
+ credentialsRef:
+ secretName: universal-auth-credentials
+ secretNamespace: default
+
+ serviceToken:
+ serviceTokenSecretReference:
+ secretName: service-token
+ secretNamespace: default
+ secretsScope:
+ envSlug:
+ secretsPath: # Root is "/"
+
+ managedSecretReference:
+ secretName: managed-secret
secretNamespace: default
- secretsScope:
- envSlug: dev
- secretsPath: "/"
- managedSecretReference:
- secretName: managed-secret # <-- the name of kubernetes secret that will be created
- secretNamespace: default # <-- where the kubernetes secret should be created
+ # secretType: kubernetes.io/dockerconfigjson
```
### InfisicalSecret CRD properties
@@ -105,11 +120,60 @@ Default re-sync interval is every 1 minute.
- This block defines the method that will be used to authenticate with Infisical so that secrets can be fetched. Currently, only [Service Tokens](../../documentation/platform/token) can be used to authenticate with Infisical.
+ This block defines the method that will be used to authenticate with Infisical so that secrets can be fetched
-
- The service token required to authenticate with Infisical needs to be stored in a Kubernetes secret. This block defines the reference to the name and name space of secret that stores this service token.
+
+ The universal machine identity authentication method is used to authenticate with Infisical. The client ID and client secret needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores these credentials.
+
+
+
+ You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about machine identities here](/documentation/platform/identities/universal-auth).
+
+
+ Once you have created your machine identity and added it to your project(s), you will need to create a Kubernetes secret containing the identity credentials.
+ To quickly create a Kubernetes secret containing the identity credentials, you can run the command below.
+
+ Make sure you replace `` with the identity client ID and `` with the identity client secret.
+
+ ``` bash
+ kubectl create secret generic universal-auth-credentials --from-literal=clientId="" --from-literal=clientSecret=""
+ ```
+
+
+
+ Once the secret is created, add the `secretName` and `secretNamespace` of the secret that was just created under `authentication.universalAuth.credentialsRef` field in the InfisicalSecret resource.
+
+
+
+
+
+
+ Make sure to also populate the `secretsScope` field with the project slug _`projectSlug`_, environment slug _`envSlug`_, and secrets path _`secretsPath`_ that you want to fetch secrets from. Please see the example below.
+
+
+ ## Example
+ ```yaml
+ apiVersion: secrets.infisical.com/v1alpha1
+ kind: InfisicalSecret
+ metadata:
+ name: infisicalsecret-sample-crd
+ spec:
+ authentication:
+ universalAuth:
+ secretsScope:
+ projectSlug: # <-- project slug
+ envSlug: # "dev", "staging", "prod", etc..
+ secretsPath: "" # Root is "/"
+ credentialsRef:
+ secretName: universal-auth-credentials # <-- name of the Kubernetes secret that stores our machine identity credentials
+ secretNamespace: default # <-- namespace of the Kubernetes secret that stores our machine identity credentials
+ ...
+ ```
+
+
+
+ The service token required to authenticate with Infisical needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores this service token.
Follow the instructions below to create and store the service token in a Kubernetes secrets and reference it in your CRD.
#### 1. Generate service token
@@ -122,13 +186,17 @@ Default re-sync interval is every 1 minute.
To quickly create a Kubernetes secret containing the generated service token, you can run the command below. Make sure you replace `` with your service token.
``` bash
- kubectl create secret generic service-token --from-literal=infisicalToken=
+ kubectl create secret generic service-token --from-literal=infisicalToken=""
```
#### 3. Add reference for the Kubernetes secret containing service token
Once the secret is created, add the name and namespace of the secret that was just created under `authentication.serviceToken.serviceTokenSecretReference` field in the InfisicalSecret resource.
+
+ Make sure to also populate the `secretsScope` field with the, environment slug _`envSlug`_, and secrets path _`secretsPath`_ that you want to fetch secrets from. Please see the example below.
+
+
## Example
```yaml
apiVersion: secrets.infisical.com/v1alpha1
@@ -141,25 +209,13 @@ Default re-sync interval is every 1 minute.
serviceTokenSecretReference:
secretName: service-token # <-- name of the Kubernetes secret that stores our service token
secretNamespace: option # <-- namespace of the Kubernetes secret that stores our service token
+ secretsScope:
+ envSlug: # "dev", "staging", "prod", etc..
+ secretsPath: # Root is "/"
...
```
-
- This block defines the scope of what secrets should be fetched. This is needed as your service token can have access to multiple folders and environments.
- A scope is defined by `envSlug` and `secretsPath`.
-
- #### envSlug
-
- This refers to the short hand name of an environment. For example for the `development` environment the environment slug is `dev`. You can locate the slug of your environment by heading to your project settings in the Infisical dashboard.
-
- #### secretsPath
-
- secretsPath is the path to the secret in the given environment. For example a path of `/` would refer to the root of the environment whereas `/folder1` would refer to the secrets in folder1 from the root.
-
- Both fields are required.
-
-
The `managedSecretReference` field is used to define the target location for storing secrets retrieved from an Infisical project.
This field requires specifying both the name and namespace of the Kubernetes secret that will hold these secrets.
diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx
index 180d44ec4..b47f38251 100644
--- a/docs/internals/security.mdx
+++ b/docs/internals/security.mdx
@@ -87,13 +87,23 @@ Since these encryption operations occur on the client-side, the Infisical API is
### High availability
-Infisical leverages the robust container orchestration capabilities of Kubernetes and the inherent high availability features of the storage backend (i.e. Bitnami MongoDB) to ensure resilience and fault tolerance.
+Infisical Cloud utilizes several strategies to ensure high availability, leveraging AWS services to maintain continuous operation and data integrity.
-- Kubernetes: By deploying multiple replicas of Infisical application on Kubernetes, operations continue even if a single instance fails. Kubernetes Services facilitate load balancing, effectively distributing traffic across your applicationโs instances and ensuring optimal performance.
-- Storage backend: Bitnami MongoDB supports replica sets, which provide data redundancy and automatic failover for the underlying database.
-- If using [Infisical Cloud](https://app.infisical.com), data is stored in a Mongo Atlas cluster with storage autoscaling and cluster tier autoscaling enabled; as you'd expect, the cluster sits on a dedicated node.
+#### Multi-AZ AWS RDS
+Infisical Cloud uses AWS Relational Database Service (RDS) with Multi-AZ deployments.
+This configuration ensures that the database service is highly available and durable.
+AWS RDS automatically provisions and maintains a synchronous standby replica of the database in a different Availability Zone (AZ).
+This setup facilitates immediate failover to the standby in the event of an AZ failure, thereby ensuring that database operations can continue with minimal interruption.
+The continuous backup and replication to the standby instance safeguard data against loss and ensure its availability even during system failures.
-Together, Kubernetesโ self-healing mechanisms and Bitnami MongoDBโs failover capabilities work to create a highly available and fault-tolerant application capable of recovering gracefully from unexpected failures.
+#### Multi-AZ ECS for Container Orchestration
+Infisical Cloud leverages Amazon Elastic Container Service (ECS) in a Multi-AZ configuration for container orchestration.
+This arrangement enables the management and operation of containers across multiple availability zones, increasing the application's fault tolerance.
+Should there be an AZ failure, load is seamlessly sent to an operational AZ, thus minimizing downtime and preserving service availability.
+
+#### Standby Regions for Regional Failover
+To fight regional outages, secondary regions are always in standby mode and maintained with up-to-date configurations and data, ready to take over in case the primary region fails.
+The standby regions enable a rapid transition and service continuity with minimal disruption in the event of a complete regional failure, ensuring that Infisical Cloud services remain accessible.
### Snapshots
diff --git a/docs/mint.json b/docs/mint.json
index 732c0f036..3ad3c0ca9 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -143,6 +143,13 @@
"documentation/platform/secret-rotation/aws-iam"
]
},
+ {
+ "group": "Dynamic Secrets",
+ "pages": [
+ "documentation/platform/dynamic-secrets/overview",
+ "documentation/platform/dynamic-secrets/postgresql"
+ ]
+ },
{
"group": "SSO",
"pages": [
@@ -467,6 +474,14 @@
"api-reference/endpoints/folders/delete"
]
},
+ {
+ "group": "Secret tags",
+ "pages": [
+ "api-reference/endpoints/secret-tags/list",
+ "api-reference/endpoints/secret-tags/create",
+ "api-reference/endpoints/secret-tags/delete"
+ ]
+ },
{
"group": "Secrets",
"pages": [
@@ -474,7 +489,9 @@
"api-reference/endpoints/secrets/create",
"api-reference/endpoints/secrets/read",
"api-reference/endpoints/secrets/update",
- "api-reference/endpoints/secrets/delete"
+ "api-reference/endpoints/secrets/delete",
+ "api-reference/endpoints/secrets/attach-tags",
+ "api-reference/endpoints/secrets/detach-tags"
]
},
{
diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx
index ae27207cd..99d2e1d79 100644
--- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx
+++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx
@@ -173,7 +173,7 @@ description: "Use Helm chart to install Infisical on your Kubernetes cluster"
After deployment, please wait for 2-5 minutes for all pods to reach a running state. Once a significant number of pods are operational, access the IP address revealed through Ingress by your load balancer.
You can find the IP address/hostname by executing the command `kubectl get ingress`.
- 
+ 
To upgrade your instance of Infisical simply update the docker image tag in your Halm values and rerun the command below.
diff --git a/docs/self-hosting/ee.mdx b/docs/self-hosting/ee.mdx
index 832d99eaf..3ff9a794a 100644
--- a/docs/self-hosting/ee.mdx
+++ b/docs/self-hosting/ee.mdx
@@ -1,11 +1,11 @@
---
-title: "Using Infisical EE"
+title: "Infisical enterprise"
description: "How to activate Infisical Enterprise Edition (EE) features"
---
While most features in Infisical are free to use, others are paid and require purchasing an enterprise license to use them.
-This guide walks through how you can use these paid features in Infisical.
+This guide walks through how you can use these paid features on a self hosted instance of Infisical.
@@ -19,10 +19,11 @@ This guide walks through how you can use these paid features in Infisical.
- If using a regular license, you should set the value of the environment variable `LICENSE_KEY` in Infisical to the issued license key.
- If using an offline license, you should set the value of the environment variable `LICENSE_KEY_OFFLINE` in Infisical to the issued license key.
- Once your instance starts up, the license key will be validated and youโll be able to use the paid features.
-
- Once the license expires, Infisical will continue to run, but EE features will be disabled until the license is renewed or a new one is purchased.
+ How you set the environment variable will depend on the deployment method you used. Please refer to the documentation of your deployment method for specific instructions.
+
+ Once your instance starts up, the license key will be validated and youโll be able to use the paid features.
+ However, when the license expires, Infisical will continue to run, but EE features will be disabled until the license is renewed or a new one is purchased.
diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
index 6666aaabf..13e8e5ab7 100644
--- a/frontend/.eslintrc.js
+++ b/frontend/.eslintrc.js
@@ -29,6 +29,7 @@ module.exports = {
},
plugins: ["react", "prettier", "simple-import-sort", "import"],
rules: {
+ "@typescript-eslint/no-empty-function": "off",
quotes: ["error", "double", { avoidEscape: true }],
"comma-dangle": ["error", "only-multiline"],
"react/react-in-jsx-scope": "off",
@@ -72,7 +73,6 @@ module.exports = {
],
"@typescript-eslint/no-non-null-assertion": "off",
"simple-import-sort/exports": "warn",
- "@typescript-eslint/no-empty-function": "off",
"simple-import-sort/imports": [
"warn",
{
diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js
index 83c1ca9e3..1a68699d2 100644
--- a/frontend/.storybook/main.js
+++ b/frontend/.storybook/main.js
@@ -1,28 +1,28 @@
-const path = require('path');
+const path = require("path");
module.exports = {
- stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
- '@storybook/addon-links',
- '@storybook/addon-essentials',
- '@storybook/addon-interactions',
- 'storybook-dark-mode',
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@storybook/addon-interactions",
+ "storybook-dark-mode",
{
- name: '@storybook/addon-styling',
+ name: "@storybook/addon-styling",
options: {
postCss: {
- implementation: require('postcss')
+ implementation: require("postcss")
}
}
}
],
framework: {
- name: '@storybook/nextjs',
+ name: "@storybook/nextjs",
options: {}
},
core: {
disableTelemetry: true
},
docs: {
- autodocs: 'tag'
+ autodocs: "tag"
}
};
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5e30edfc3..4eb7e76fd 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",
@@ -83,6 +85,7 @@
"react-markdown": "^8.0.3",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
+ "react-toastify": "^9.1.3",
"sanitize-html": "^2.12.1",
"set-cookie-parser": "^2.5.1",
"sharp": "^0.33.2",
@@ -9911,13 +9914,13 @@
"dev": true
},
"node_modules/body-parser": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
- "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
+ "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
"dev": true,
"dependencies": {
"bytes": "3.1.2",
- "content-type": "~1.0.4",
+ "content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
@@ -9925,7 +9928,7 @@
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
- "raw-body": "2.5.1",
+ "raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
@@ -10874,9 +10877,9 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cookie": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
- "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"engines": {
"node": ">= 0.6"
@@ -11455,6 +11458,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",
@@ -13255,17 +13263,17 @@
}
},
"node_modules/express": {
- "version": "4.18.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
- "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
+ "version": "4.19.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
+ "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
"dev": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
- "body-parser": "1.20.1",
+ "body-parser": "1.20.2",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
- "cookie": "0.5.0",
+ "cookie": "0.6.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@@ -13759,9 +13767,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 +15134,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 +17526,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",
@@ -19786,9 +19794,9 @@
}
},
"node_modules/raw-body": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
- "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dev": true,
"dependencies": {
"bytes": "3.1.2",
@@ -20293,6 +20301,26 @@
"react": "^16.8.3 || ^17.0.0-0 || ^18.0.0"
}
},
+ "node_modules/react-toastify": {
+ "version": "9.1.3",
+ "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz",
+ "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==",
+ "dependencies": {
+ "clsx": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
+ "node_modules/react-toastify/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -21287,12 +21315,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",
@@ -23700,9 +23722,9 @@
}
},
"node_modules/webpack-dev-middleware": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.1.tgz",
- "integrity": "sha512-y51HrHaFeeWir0YO4f0g+9GwZawuigzcAdRNon6jErXy/SqV/+O6eaVAzDqE6t3e3NpGeR5CS+cCDaTC+V3yEQ==",
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.2.tgz",
+ "integrity": "sha512-Wu+EHmX326YPYUpQLKmKbTyZZJIB8/n6R09pTmB03kJmnMsVPTo9COzHZFr01txwaCAuZvfBJE4ZCHRcKs5JaQ==",
"dev": true,
"dependencies": {
"colorette": "^2.0.10",
diff --git a/frontend/package.json b/frontend/package.json
index 6a1666f05..0dacef6b3 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",
@@ -91,6 +92,7 @@
"react-markdown": "^8.0.3",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
+ "react-toastify": "^9.1.3",
"sanitize-html": "^2.12.1",
"set-cookie-parser": "^2.5.1",
"sharp": "^0.33.2",
diff --git a/frontend/src/components/analytics/posthog.ts b/frontend/src/components/analytics/posthog.ts
index 246d5c9cc..cc26e5512 100644
--- a/frontend/src/components/analytics/posthog.ts
+++ b/frontend/src/components/analytics/posthog.ts
@@ -6,7 +6,7 @@ import { ENV, POSTHOG_API_KEY, POSTHOG_HOST } from "../utilities/config";
export const initPostHog = () => {
// @ts-ignore
- console.log("Hi there ๐")
+ console.log("Hi there ๐");
try {
if (typeof window !== "undefined") {
// @ts-ignore
@@ -19,7 +19,7 @@ export const initPostHog = () => {
return posthog;
} catch (e) {
- console.log("posthog err", e)
+ console.log("posthog err", e);
}
return undefined;
diff --git a/frontend/src/components/basic/Error.tsx b/frontend/src/components/basic/Error.tsx
index bf892e03d..1ef937e4a 100644
--- a/frontend/src/components/basic/Error.tsx
+++ b/frontend/src/components/basic/Error.tsx
@@ -3,9 +3,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const Error = ({ text }: { text: string }): JSX.Element => {
return (
-
-
- {text &&
{text}
}
+
);
};
diff --git a/frontend/src/components/basic/InputField.tsx b/frontend/src/components/basic/InputField.tsx
index 0bc01defc..346294e56 100644
--- a/frontend/src/components/basic/InputField.tsx
+++ b/frontend/src/components/basic/InputField.tsx
@@ -39,16 +39,16 @@ const InputField = ({
if (isStatic === true) {
return (
-
-
{label}
- {text &&
{text}
}
+
+
{label}
+ {text &&
{text}
}
onChangeHandler(e.target.value)}
type={type}
placeholder={placeholder}
value={value}
required={isRequired}
- className="bg-bunker-800 text-gray-400 border border-gray-600 rounded-md text-md p-2 w-full min-w-16 outline-none"
+ className="text-md min-w-16 w-full rounded-md border border-gray-600 bg-bunker-800 p-2 text-gray-400 outline-none"
name={name}
readOnly
autoComplete={autoComplete}
@@ -58,12 +58,12 @@ const InputField = ({
);
}
return (
-
-
-
{label}
+
+
@@ -75,11 +75,11 @@ const InputField = ({
required={isRequired}
className={`${
blurred
- ? "text-bunker-800 group-hover:text-gray-400 focus:text-gray-400 active:text-gray-400"
+ ? "text-bunker-800 focus:text-gray-400 active:text-gray-400 group-hover:text-gray-400"
: ""
} ${
error ? "focus:ring-red/50" : "focus:ring-primary/50"
- } relative peer bg-mineshaft-900 rounded-md text-gray-400 text-md p-2 w-full min-w-16 outline-none focus:ring-4 duration-200`}
+ } text-md min-w-16 peer relative w-full rounded-md bg-mineshaft-900 p-2 text-gray-400 outline-none duration-200 focus:ring-4`}
name={name}
spellCheck="false"
autoComplete={autoComplete}
@@ -91,7 +91,7 @@ const InputField = ({
onClick={() => {
setPasswordVisible(!passwordVisible);
}}
- className="absolute self-end mr-3 text-gray-400 cursor-pointer"
+ className="absolute mr-3 cursor-pointer self-end text-gray-400"
>
{passwordVisible ? (
@@ -101,7 +101,7 @@ const InputField = ({
)}
{blurred && (
-
+
{value
.split("")
@@ -109,7 +109,7 @@ const InputField = ({
.map(() => (
))}
@@ -121,7 +121,7 @@ const InputField = ({
)} */}
- {error &&
{errorText}
}
+ {error &&
{errorText}
}
);
};
diff --git a/frontend/src/components/basic/Listbox.tsx b/frontend/src/components/basic/Listbox.tsx
index 5cdeb26d9..cad9aaab4 100644
--- a/frontend/src/components/basic/Listbox.tsx
+++ b/frontend/src/components/basic/Listbox.tsx
@@ -34,19 +34,19 @@ const ListBox = ({
{text}
-
+
{" "}
{isSelected}
{data && (
-
+
)}
@@ -58,16 +58,16 @@ const ListBox = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
-
+
{data.map((person, personIdx) => (
- `my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md ${
- selected ? "bg-white/10 text-gray-400 font-bold" : ""
+ `relative my-0.5 cursor-default select-none rounded-md py-2 pl-10 pr-4 ${
+ selected ? "bg-white/10 font-bold text-gray-400" : ""
} ${
active && !selected
- ? "bg-white/5 text-mineshaft-200 cursor-pointer"
+ ? "cursor-pointer bg-white/5 text-mineshaft-200"
: "text-gray-400"
} `
}
@@ -83,7 +83,7 @@ const ListBox = ({
{person}
{selected ? (
-
+
) : null}
@@ -92,9 +92,9 @@ const ListBox = ({
))}
{buttonAction && (
-