mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 11:27:32 +00:00
Merge pull request #1611 from akhilmhdh/feat/pg-dynamic-secret
Dynamic secret #Postgres
This commit is contained in:
Vendored
+5
-1
@@ -21,6 +21,8 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service";
|
|||||||
import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service";
|
import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service";
|
||||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||||
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||||
|
import { TDynamicSecretServiceFactory } from "@app/services/dynamic-secret/dynamic-secret-service";
|
||||||
|
import { TDynamicSecretLeaseServiceFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-service";
|
||||||
import { TIdentityServiceFactory } from "@app/services/identity/identity-service";
|
import { TIdentityServiceFactory } from "@app/services/identity/identity-service";
|
||||||
import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
|
import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
|
||||||
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
|
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
|
||||||
@@ -62,7 +64,7 @@ declare module "fastify" {
|
|||||||
authMethod: ActorAuthMethod;
|
authMethod: ActorAuthMethod;
|
||||||
type: ActorType;
|
type: ActorType;
|
||||||
id: string;
|
id: string;
|
||||||
orgId?: string;
|
orgId: string;
|
||||||
};
|
};
|
||||||
// passport data
|
// passport data
|
||||||
passportUser: {
|
passportUser: {
|
||||||
@@ -117,6 +119,8 @@ declare module "fastify" {
|
|||||||
trustedIp: TTrustedIpServiceFactory;
|
trustedIp: TTrustedIpServiceFactory;
|
||||||
secretBlindIndex: TSecretBlindIndexServiceFactory;
|
secretBlindIndex: TSecretBlindIndexServiceFactory;
|
||||||
telemetry: TTelemetryServiceFactory;
|
telemetry: TTelemetryServiceFactory;
|
||||||
|
dynamicSecret: TDynamicSecretServiceFactory;
|
||||||
|
dynamicSecretLease: TDynamicSecretLeaseServiceFactory;
|
||||||
};
|
};
|
||||||
// this is exclusive use for middlewares in which we need to inject data
|
// this is exclusive use for middlewares in which we need to inject data
|
||||||
// everywhere else access using service layer
|
// everywhere else access using service layer
|
||||||
|
|||||||
Vendored
+12
@@ -17,6 +17,12 @@ import {
|
|||||||
TBackupPrivateKey,
|
TBackupPrivateKey,
|
||||||
TBackupPrivateKeyInsert,
|
TBackupPrivateKeyInsert,
|
||||||
TBackupPrivateKeyUpdate,
|
TBackupPrivateKeyUpdate,
|
||||||
|
TDynamicSecretLeases,
|
||||||
|
TDynamicSecretLeasesInsert,
|
||||||
|
TDynamicSecretLeasesUpdate,
|
||||||
|
TDynamicSecrets,
|
||||||
|
TDynamicSecretsInsert,
|
||||||
|
TDynamicSecretsUpdate,
|
||||||
TGitAppInstallSessions,
|
TGitAppInstallSessions,
|
||||||
TGitAppInstallSessionsInsert,
|
TGitAppInstallSessionsInsert,
|
||||||
TGitAppInstallSessionsUpdate,
|
TGitAppInstallSessionsUpdate,
|
||||||
@@ -340,6 +346,12 @@ declare module "knex/types/tables" {
|
|||||||
TSecretSnapshotFoldersInsert,
|
TSecretSnapshotFoldersInsert,
|
||||||
TSecretSnapshotFoldersUpdate
|
TSecretSnapshotFoldersUpdate
|
||||||
>;
|
>;
|
||||||
|
[TableName.DynamicSecret]: Knex.CompositeTableType<TDynamicSecrets, TDynamicSecretsInsert, TDynamicSecretsUpdate>;
|
||||||
|
[TableName.DynamicSecretLease]: Knex.CompositeTableType<
|
||||||
|
TDynamicSecretLeases,
|
||||||
|
TDynamicSecretLeasesInsert,
|
||||||
|
TDynamicSecretLeasesUpdate
|
||||||
|
>;
|
||||||
[TableName.SamlConfig]: Knex.CompositeTableType<TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate>;
|
[TableName.SamlConfig]: Knex.CompositeTableType<TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate>;
|
||||||
[TableName.LdapConfig]: Knex.CompositeTableType<TLdapConfigs, TLdapConfigsInsert, TLdapConfigsUpdate>;
|
[TableName.LdapConfig]: Knex.CompositeTableType<TLdapConfigs, TLdapConfigsInsert, TLdapConfigsUpdate>;
|
||||||
[TableName.OrgBot]: Knex.CompositeTableType<TOrgBots, TOrgBotsInsert, TOrgBotsUpdate>;
|
[TableName.OrgBot]: Knex.CompositeTableType<TOrgBots, TOrgBotsInsert, TOrgBotsUpdate>;
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.DynamicSecretLease);
|
||||||
|
await knex.schema.dropTableIfExists(TableName.DynamicSecretLease);
|
||||||
|
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.DynamicSecret);
|
||||||
|
await knex.schema.dropTableIfExists(TableName.DynamicSecret);
|
||||||
|
}
|
||||||
@@ -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<typeof DynamicSecretLeasesSchema>;
|
||||||
|
export type TDynamicSecretLeasesInsert = Omit<z.input<typeof DynamicSecretLeasesSchema>, TImmutableDBKeys>;
|
||||||
|
export type TDynamicSecretLeasesUpdate = Partial<Omit<z.input<typeof DynamicSecretLeasesSchema>, TImmutableDBKeys>>;
|
||||||
@@ -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<typeof DynamicSecretsSchema>;
|
||||||
|
export type TDynamicSecretsInsert = Omit<z.input<typeof DynamicSecretsSchema>, TImmutableDBKeys>;
|
||||||
|
export type TDynamicSecretsUpdate = Partial<Omit<z.input<typeof DynamicSecretsSchema>, TImmutableDBKeys>>;
|
||||||
@@ -3,6 +3,8 @@ export * from "./audit-logs";
|
|||||||
export * from "./auth-token-sessions";
|
export * from "./auth-token-sessions";
|
||||||
export * from "./auth-tokens";
|
export * from "./auth-tokens";
|
||||||
export * from "./backup-private-key";
|
export * from "./backup-private-key";
|
||||||
|
export * from "./dynamic-secret-leases";
|
||||||
|
export * from "./dynamic-secrets";
|
||||||
export * from "./git-app-install-sessions";
|
export * from "./git-app-install-sessions";
|
||||||
export * from "./git-app-org";
|
export * from "./git-app-org";
|
||||||
export * from "./identities";
|
export * from "./identities";
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export enum TableName {
|
|||||||
GitAppOrg = "git_app_org",
|
GitAppOrg = "git_app_org",
|
||||||
SecretScanningGitRisk = "secret_scanning_git_risks",
|
SecretScanningGitRisk = "secret_scanning_git_risks",
|
||||||
TrustedIps = "trusted_ips",
|
TrustedIps = "trusted_ips",
|
||||||
|
DynamicSecret = "dynamic_secrets",
|
||||||
|
DynamicSecretLease = "dynamic_secret_leases",
|
||||||
// junction tables with tags
|
// junction tables with tags
|
||||||
JnSecretTag = "secret_tag_junction",
|
JnSecretTag = "secret_tag_junction",
|
||||||
SecretVersionTag = "secret_version_tag_junction"
|
SecretVersionTag = "secret_version_tag_junction"
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
|||||||
offset: req.query.startIndex,
|
offset: req.query.startIndex,
|
||||||
limit: req.query.count,
|
limit: req.query.count,
|
||||||
filter: req.query.filter,
|
filter: req.query.filter,
|
||||||
orgId: req.permission.orgId as string
|
orgId: req.permission.orgId
|
||||||
});
|
});
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const user = await req.server.services.scim.getScimUser({
|
const user = await req.server.services.scim.getScimUser({
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
orgId: req.permission.orgId as string
|
orgId: req.permission.orgId
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
|||||||
email: primaryEmail,
|
email: primaryEmail,
|
||||||
firstName: req.body.name.givenName,
|
firstName: req.body.name.givenName,
|
||||||
lastName: req.body.name.familyName,
|
lastName: req.body.name.familyName,
|
||||||
orgId: req.permission.orgId as string
|
orgId: req.permission.orgId
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -280,7 +280,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const user = await req.server.services.scim.updateScimUser({
|
const user = await req.server.services.scim.updateScimUser({
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
orgId: req.permission.orgId as string,
|
orgId: req.permission.orgId,
|
||||||
operations: req.body.Operations
|
operations: req.body.Operations
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
@@ -330,7 +330,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const user = await req.server.services.scim.replaceScimUser({
|
const user = await req.server.services.scim.replaceScimUser({
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
orgId: req.permission.orgId as string,
|
orgId: req.permission.orgId,
|
||||||
active: req.body.active
|
active: req.body.active
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
|||||||
membersUsed: 0,
|
membersUsed: 0,
|
||||||
environmentLimit: null,
|
environmentLimit: null,
|
||||||
environmentsUsed: 0,
|
environmentsUsed: 0,
|
||||||
|
dynamicSecret: false,
|
||||||
secretVersioning: true,
|
secretVersioning: true,
|
||||||
pitRecovery: false,
|
pitRecovery: false,
|
||||||
ipAllowlisting: false,
|
ipAllowlisting: false,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type TFeatureSet = {
|
|||||||
tier: -1;
|
tier: -1;
|
||||||
workspaceLimit: null;
|
workspaceLimit: null;
|
||||||
workspacesUsed: 0;
|
workspacesUsed: 0;
|
||||||
|
dynamicSecret: false;
|
||||||
memberLimit: null;
|
memberLimit: null;
|
||||||
membersUsed: 0;
|
membersUsed: 0;
|
||||||
environmentLimit: null;
|
environmentLimit: null;
|
||||||
|
|||||||
@@ -305,6 +305,83 @@ export const AUDIT_LOGS = {
|
|||||||
}
|
}
|
||||||
} as const;
|
} 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 = {
|
export const SECRET_TAGS = {
|
||||||
LIST: {
|
LIST: {
|
||||||
projectId: "The ID of the project to list tags from."
|
projectId: "The ID of the project to list tags from."
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const envSchema = z
|
|||||||
DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default(
|
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}`
|
`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_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()),
|
||||||
DB_HOST: zpStr(z.string().describe("Postgres database host").optional()),
|
DB_HOST: zpStr(z.string().describe("Postgres database host").optional()),
|
||||||
DB_PORT: zpStr(z.string().describe("Postgres database port").optional()).default("5432"),
|
DB_PORT: zpStr(z.string().describe("Postgres database port").optional()).default("5432"),
|
||||||
|
|||||||
@@ -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 {
|
export class ScimRequestError extends Error {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type TProjectPermission = {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
actorAuthMethod: ActorAuthMethod;
|
actorAuthMethod: ActorAuthMethod;
|
||||||
actorOrgId: string | undefined;
|
actorOrgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequiredKeys<T> = {
|
export type RequiredKeys<T> = {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export enum QueueName {
|
|||||||
SecretWebhook = "secret-webhook",
|
SecretWebhook = "secret-webhook",
|
||||||
SecretFullRepoScan = "secret-full-repo-scan",
|
SecretFullRepoScan = "secret-full-repo-scan",
|
||||||
SecretPushEventScan = "secret-push-event-scan",
|
SecretPushEventScan = "secret-push-event-scan",
|
||||||
UpgradeProjectToGhost = "upgrade-project-to-ghost"
|
UpgradeProjectToGhost = "upgrade-project-to-ghost",
|
||||||
|
DynamicSecretRevocation = "dynamic-secret-revocation"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJobs {
|
export enum QueueJobs {
|
||||||
@@ -30,7 +31,9 @@ export enum QueueJobs {
|
|||||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||||
IntegrationSync = "secret-integration-pull",
|
IntegrationSync = "secret-integration-pull",
|
||||||
SecretScan = "secret-scan",
|
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 = {
|
export type TQueueJobTypes = {
|
||||||
@@ -86,6 +89,19 @@ export type TQueueJobTypes = {
|
|||||||
name: QueueJobs.TelemetryInstanceStats;
|
name: QueueJobs.TelemetryInstanceStats;
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
[QueueName.DynamicSecretRevocation]:
|
||||||
|
| {
|
||||||
|
name: QueueJobs.DynamicSecretRevocation;
|
||||||
|
payload: {
|
||||||
|
leaseId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: QueueJobs.DynamicSecretPruning;
|
||||||
|
payload: {
|
||||||
|
dynamicSecretCfgId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type TAuthMode =
|
|||||||
userId: string;
|
userId: string;
|
||||||
tokenVersionId: string; // the session id of token used
|
tokenVersionId: string; // the session id of token used
|
||||||
user: TUsers;
|
user: TUsers;
|
||||||
orgId?: string;
|
orgId: string;
|
||||||
authMethod: AuthMethod;
|
authMethod: AuthMethod;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -119,7 +119,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
tokenVersionId,
|
tokenVersionId,
|
||||||
actor,
|
actor,
|
||||||
orgId,
|
orgId: orgId as string,
|
||||||
authMethod: token.authMethod
|
authMethod: token.authMethod
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv
|
|||||||
import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service";
|
import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service";
|
||||||
import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal";
|
import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal";
|
||||||
import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||||
|
import { dynamicSecretDALFactory } from "@app/services/dynamic-secret/dynamic-secret-dal";
|
||||||
|
import { dynamicSecretServiceFactory } from "@app/services/dynamic-secret/dynamic-secret-service";
|
||||||
|
import { buildDynamicSecretProviders } from "@app/services/dynamic-secret/providers";
|
||||||
|
import { dynamicSecretLeaseDALFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-dal";
|
||||||
|
import { dynamicSecretLeaseQueueServiceFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-queue";
|
||||||
|
import { dynamicSecretLeaseServiceFactory } from "@app/services/dynamic-secret-lease/dynamic-secret-lease-service";
|
||||||
import { identityDALFactory } from "@app/services/identity/identity-dal";
|
import { identityDALFactory } from "@app/services/identity/identity-dal";
|
||||||
import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
|
import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
|
||||||
import { identityServiceFactory } from "@app/services/identity/identity-service";
|
import { identityServiceFactory } from "@app/services/identity/identity-service";
|
||||||
@@ -196,6 +202,8 @@ export const registerRoutes = async (
|
|||||||
const gitAppOrgDAL = gitAppDALFactory(db);
|
const gitAppOrgDAL = gitAppDALFactory(db);
|
||||||
const secretScanningDAL = secretScanningDALFactory(db);
|
const secretScanningDAL = secretScanningDALFactory(db);
|
||||||
const licenseDAL = licenseDALFactory(db);
|
const licenseDAL = licenseDALFactory(db);
|
||||||
|
const dynamicSecretDAL = dynamicSecretDALFactory(db);
|
||||||
|
const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db);
|
||||||
|
|
||||||
const permissionService = permissionServiceFactory({
|
const permissionService = permissionServiceFactory({
|
||||||
permissionDAL,
|
permissionDAL,
|
||||||
@@ -550,6 +558,34 @@ export const registerRoutes = async (
|
|||||||
licenseService
|
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();
|
await superAdminService.initServerCfg();
|
||||||
//
|
//
|
||||||
// setup the communication with license key server
|
// setup the communication with license key server
|
||||||
@@ -591,6 +627,8 @@ export const registerRoutes = async (
|
|||||||
secretApprovalPolicy: sapService,
|
secretApprovalPolicy: sapService,
|
||||||
secretApprovalRequest: sarService,
|
secretApprovalRequest: sarService,
|
||||||
secretRotation: secretRotationService,
|
secretRotation: secretRotationService,
|
||||||
|
dynamicSecret: dynamicSecretService,
|
||||||
|
dynamicSecretLease: dynamicSecretLeaseService,
|
||||||
snapshot: snapshotService,
|
snapshot: snapshotService,
|
||||||
saml: samlService,
|
saml: samlService,
|
||||||
ldap: ldapService,
|
ldap: ldapService,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
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
|
// sometimes the return data must be santizied to avoid leaking important values
|
||||||
// always prefer pick over omit in zod
|
// always prefer pick over omit in zod
|
||||||
@@ -56,3 +61,11 @@ export const secretRawSchema = z.object({
|
|||||||
secretValue: z.string(),
|
secretValue: z.string(),
|
||||||
secretComment: z.string().optional()
|
secretComment: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({
|
||||||
|
inputIV: true,
|
||||||
|
inputTag: true,
|
||||||
|
inputCiphertext: true,
|
||||||
|
keyEncoding: true,
|
||||||
|
algorithm: true
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||||
|
import { DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
|
||||||
|
import { daysToMillisecond } from "@app/lib/dates";
|
||||||
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
import { SanitizedDynamicSecretSchema } from "../sanitizedSchemas";
|
||||||
|
|
||||||
|
export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(),
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug),
|
||||||
|
ttl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(DYNAMIC_SECRET_LEASES.CREATE.ttl)
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > daysToMillisecond(1))
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
lease: DynamicSecretLeasesSchema,
|
||||||
|
dynamicSecret: SanitizedDynamicSecretSchema,
|
||||||
|
data: z.unknown()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
name: req.body.dynamicSecretName,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
return { lease, data, dynamicSecret };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:leaseId",
|
||||||
|
method: "DELETE",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.leaseId)
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.projectSlug),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.trim()
|
||||||
|
.default("/")
|
||||||
|
.transform(removeTrailingSlash)
|
||||||
|
.describe(DYNAMIC_SECRET_LEASES.DELETE.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.environmentSlug),
|
||||||
|
isForced: z.boolean().default(false).describe(DYNAMIC_SECRET_LEASES.DELETE.isForced)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
lease: DynamicSecretLeasesSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const lease = await server.services.dynamicSecretLease.revokeLease({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
leaseId: req.params.leaseId,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
return { lease };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:leaseId/renew",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.leaseId)
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
ttl: z
|
||||||
|
.string()
|
||||||
|
.describe(DYNAMIC_SECRET_LEASES.RENEW.ttl)
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > daysToMillisecond(1))
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.projectSlug),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.trim()
|
||||||
|
.default("/")
|
||||||
|
.transform(removeTrailingSlash)
|
||||||
|
.describe(DYNAMIC_SECRET_LEASES.RENEW.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.ttl)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
lease: DynamicSecretLeasesSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const lease = await server.services.dynamicSecretLease.renewLease({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
leaseId: req.params.leaseId,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
return { lease };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:leaseId",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.leaseId)
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.projectSlug),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.default("/")
|
||||||
|
.transform(removeTrailingSlash)
|
||||||
|
.describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.environmentSlug)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
lease: DynamicSecretLeasesSchema.extend({
|
||||||
|
dynamicSecret: SanitizedDynamicSecretSchema
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const lease = await server.services.dynamicSecretLease.getLeaseDetails({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
leaseId: req.params.leaseId,
|
||||||
|
...req.query
|
||||||
|
});
|
||||||
|
return { lease };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||||
|
import { DYNAMIC_SECRETS } from "@app/lib/api-docs";
|
||||||
|
import { daysToMillisecond } from "@app/lib/dates";
|
||||||
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
import { DynamicSecretProviderSchema } from "@app/services/dynamic-secret/providers/models";
|
||||||
|
|
||||||
|
import { SanitizedDynamicSecretSchema } from "../sanitizedSchemas";
|
||||||
|
|
||||||
|
export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.CREATE.projectSlug),
|
||||||
|
provider: DynamicSecretProviderSchema.describe(DYNAMIC_SECRETS.CREATE.provider),
|
||||||
|
defaultTTL: z
|
||||||
|
.string()
|
||||||
|
.describe(DYNAMIC_SECRETS.CREATE.defaultTTL)
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > daysToMillisecond(1))
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.describe(DYNAMIC_SECRETS.CREATE.maxTTL)
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > daysToMillisecond(1))
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash),
|
||||||
|
environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(DYNAMIC_SECRETS.CREATE.name)
|
||||||
|
.min(1)
|
||||||
|
.toLowerCase()
|
||||||
|
.max(64)
|
||||||
|
.refine((v) => slugify(v) === v, {
|
||||||
|
message: "Slug must be a valid"
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
dynamicSecret: SanitizedDynamicSecretSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const dynamicSecretCfg = await server.services.dynamicSecret.create({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
return { dynamicSecret: dynamicSecretCfg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:name",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
name: z.string().toLowerCase().describe(DYNAMIC_SECRETS.UPDATE.name)
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.UPDATE.projectSlug),
|
||||||
|
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.UPDATE.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.UPDATE.environmentSlug),
|
||||||
|
data: z.object({
|
||||||
|
inputs: z.any().optional().describe(DYNAMIC_SECRETS.UPDATE.inputs),
|
||||||
|
defaultTTL: z
|
||||||
|
.string()
|
||||||
|
.describe(DYNAMIC_SECRETS.UPDATE.defaultTTL)
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > daysToMillisecond(1))
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.describe(DYNAMIC_SECRETS.UPDATE.maxTTL)
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
if (valMs > daysToMillisecond(1))
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
dynamicSecret: SanitizedDynamicSecretSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const dynamicSecretCfg = await server.services.dynamicSecret.updateByName({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
name: req.params.name,
|
||||||
|
path: req.body.path,
|
||||||
|
projectSlug: req.body.projectSlug,
|
||||||
|
environmentSlug: req.body.environmentSlug,
|
||||||
|
...req.body.data
|
||||||
|
});
|
||||||
|
return { dynamicSecret: dynamicSecretCfg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:name",
|
||||||
|
method: "DELETE",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
name: z.string().toLowerCase().describe(DYNAMIC_SECRETS.DELETE.name)
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.DELETE.projectSlug),
|
||||||
|
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.DELETE.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.DELETE.environmentSlug),
|
||||||
|
isForced: z.boolean().default(false).describe(DYNAMIC_SECRETS.DELETE.isForced)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
dynamicSecret: SanitizedDynamicSecretSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const dynamicSecretCfg = await server.services.dynamicSecret.deleteByName({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
name: req.params.name,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
return { dynamicSecret: dynamicSecretCfg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:name",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
name: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.name)
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.projectSlug),
|
||||||
|
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.GET_BY_NAME.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.environmentSlug)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
dynamicSecret: SanitizedDynamicSecretSchema.extend({
|
||||||
|
inputs: z.unknown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const dynamicSecretCfg = await server.services.dynamicSecret.getDetails({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
name: req.params.name,
|
||||||
|
...req.query
|
||||||
|
});
|
||||||
|
return { dynamicSecret: dynamicSecretCfg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST.projectSlug),
|
||||||
|
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.LIST.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST.environmentSlug)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
dynamicSecrets: SanitizedDynamicSecretSchema.array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const dynamicSecretCfgs = await server.services.dynamicSecret.list({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.query
|
||||||
|
});
|
||||||
|
return { dynamicSecrets: dynamicSecretCfgs };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:name/leases",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
name: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.name)
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.projectSlug),
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.default("/")
|
||||||
|
.transform(removeTrailingSlash)
|
||||||
|
.describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.path),
|
||||||
|
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.environmentSlug)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
leases: DynamicSecretLeasesSchema.array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const leases = await server.services.dynamicSecretLease.listLeases({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
name: req.params.name,
|
||||||
|
...req.query
|
||||||
|
});
|
||||||
|
return { leases };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { registerAdminRouter } from "./admin-router";
|
import { registerAdminRouter } from "./admin-router";
|
||||||
import { registerAuthRoutes } from "./auth-router";
|
import { registerAuthRoutes } from "./auth-router";
|
||||||
import { registerProjectBotRouter } from "./bot-router";
|
import { registerProjectBotRouter } from "./bot-router";
|
||||||
|
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
|
||||||
|
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
|
||||||
import { registerIdentityAccessTokenRouter } from "./identity-access-token-router";
|
import { registerIdentityAccessTokenRouter } from "./identity-access-token-router";
|
||||||
import { registerIdentityRouter } from "./identity-router";
|
import { registerIdentityRouter } from "./identity-router";
|
||||||
import { registerIdentityUaRouter } from "./identity-ua";
|
import { registerIdentityUaRouter } from "./identity-ua";
|
||||||
@@ -52,6 +54,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
|||||||
{ prefix: "/workspace" }
|
{ prefix: "/workspace" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await server.register(
|
||||||
|
async (dynamicSecretRouter) => {
|
||||||
|
await dynamicSecretRouter.register(registerDynamicSecretRouter);
|
||||||
|
await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" });
|
||||||
|
},
|
||||||
|
{ prefix: "/dynamic-secrets" }
|
||||||
|
);
|
||||||
|
|
||||||
await server.register(registerProjectBotRouter, { prefix: "/bot" });
|
await server.register(registerProjectBotRouter, { prefix: "/bot" });
|
||||||
await server.register(registerIntegrationRouter, { prefix: "/integration" });
|
await server.register(registerIntegrationRouter, { prefix: "/integration" });
|
||||||
await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" });
|
await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" });
|
||||||
|
|||||||
@@ -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<typeof dynamicSecretLeaseDALFactory>;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -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<TDynamicSecretLeaseDALFactory, "findById" | "deleteById" | "find" | "updateById">;
|
||||||
|
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById" | "updateById">;
|
||||||
|
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDynamicSecretLeaseQueueServiceFactory = ReturnType<typeof dynamicSecretLeaseQueueServiceFactory>;
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
import ms from "ms";
|
||||||
|
|
||||||
|
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||||
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
|
import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
|
||||||
|
import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models";
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
|
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
|
||||||
|
import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue";
|
||||||
|
import {
|
||||||
|
DynamicSecretLeaseStatus,
|
||||||
|
TCreateDynamicSecretLeaseDTO,
|
||||||
|
TDeleteDynamicSecretLeaseDTO,
|
||||||
|
TDetailsDynamicSecretLeaseDTO,
|
||||||
|
TListDynamicSecretLeasesDTO,
|
||||||
|
TRenewDynamicSecretLeaseDTO
|
||||||
|
} from "./dynamic-secret-lease-types";
|
||||||
|
|
||||||
|
type TDynamicSecretLeaseServiceFactoryDep = {
|
||||||
|
dynamicSecretLeaseDAL: TDynamicSecretLeaseDALFactory;
|
||||||
|
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findOne">;
|
||||||
|
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
|
||||||
|
dynamicSecretQueueService: TDynamicSecretLeaseQueueServiceFactory;
|
||||||
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDynamicSecretLeaseServiceFactory = ReturnType<typeof dynamicSecretLeaseServiceFactory>;
|
||||||
|
|
||||||
|
export const dynamicSecretLeaseServiceFactory = ({
|
||||||
|
dynamicSecretLeaseDAL,
|
||||||
|
dynamicSecretProviders,
|
||||||
|
dynamicSecretDAL,
|
||||||
|
folderDAL,
|
||||||
|
permissionService,
|
||||||
|
dynamicSecretQueueService,
|
||||||
|
projectDAL,
|
||||||
|
licenseService
|
||||||
|
}: TDynamicSecretLeaseServiceFactoryDep) => {
|
||||||
|
const create = async ({
|
||||||
|
environmentSlug,
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
projectSlug,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
ttl
|
||||||
|
}: TCreateDynamicSecretLeaseDTO) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(actorOrgId);
|
||||||
|
if (!plan?.dynamicSecret) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to create lease due to plan restriction. Upgrade plan to create dynamic secret."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||||
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
|
||||||
|
const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id);
|
||||||
|
if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT)
|
||||||
|
throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` });
|
||||||
|
|
||||||
|
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
||||||
|
const decryptedStoredInput = JSON.parse(
|
||||||
|
infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
|
||||||
|
ciphertext: dynamicSecretCfg.inputCiphertext,
|
||||||
|
tag: dynamicSecretCfg.inputTag,
|
||||||
|
iv: dynamicSecretCfg.inputIV
|
||||||
|
})
|
||||||
|
) as object;
|
||||||
|
|
||||||
|
const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL;
|
||||||
|
const { maxTTL } = dynamicSecretCfg;
|
||||||
|
const expireAt = new Date(new Date().getTime() + ms(selectedTTL));
|
||||||
|
if (maxTTL) {
|
||||||
|
const maxExpiryDate = new Date(new Date().getTime() + ms(maxTTL));
|
||||||
|
if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max TTL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entityId, data } = await selectedProvider.create(decryptedStoredInput, expireAt.getTime());
|
||||||
|
const dynamicSecretLease = await dynamicSecretLeaseDAL.create({
|
||||||
|
expireAt,
|
||||||
|
version: 1,
|
||||||
|
dynamicSecretId: dynamicSecretCfg.id,
|
||||||
|
externalEntityId: entityId
|
||||||
|
});
|
||||||
|
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
|
||||||
|
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
|
||||||
|
};
|
||||||
|
|
||||||
|
const renewLease = async ({
|
||||||
|
ttl,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
projectSlug,
|
||||||
|
path,
|
||||||
|
environmentSlug,
|
||||||
|
leaseId
|
||||||
|
}: TRenewDynamicSecretLeaseDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(actorOrgId);
|
||||||
|
if (!plan?.dynamicSecret) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to renew lease due to plan restriction. Upgrade plan to create dynamic secret."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId);
|
||||||
|
if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret;
|
||||||
|
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
||||||
|
const decryptedStoredInput = JSON.parse(
|
||||||
|
infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
|
||||||
|
ciphertext: dynamicSecretCfg.inputCiphertext,
|
||||||
|
tag: dynamicSecretCfg.inputTag,
|
||||||
|
iv: dynamicSecretCfg.inputIV
|
||||||
|
})
|
||||||
|
) as object;
|
||||||
|
|
||||||
|
const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL;
|
||||||
|
const { maxTTL } = dynamicSecretCfg;
|
||||||
|
const expireAt = new Date(dynamicSecretLease.expireAt.getTime() + ms(selectedTTL));
|
||||||
|
if (maxTTL) {
|
||||||
|
const maxExpiryDate = new Date(dynamicSecretLease.createdAt.getTime() + ms(maxTTL));
|
||||||
|
if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max ttl" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entityId } = await selectedProvider.renew(
|
||||||
|
decryptedStoredInput,
|
||||||
|
dynamicSecretLease.externalEntityId,
|
||||||
|
expireAt.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
|
||||||
|
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
|
||||||
|
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||||
|
expireAt,
|
||||||
|
externalEntityId: entityId
|
||||||
|
});
|
||||||
|
return updatedDynamicSecretLease;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeLease = async ({
|
||||||
|
leaseId,
|
||||||
|
environmentSlug,
|
||||||
|
path,
|
||||||
|
projectSlug,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
isForced
|
||||||
|
}: TDeleteDynamicSecretLeaseDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId);
|
||||||
|
if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret;
|
||||||
|
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
||||||
|
const decryptedStoredInput = JSON.parse(
|
||||||
|
infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
|
||||||
|
ciphertext: dynamicSecretCfg.inputCiphertext,
|
||||||
|
tag: dynamicSecretCfg.inputTag,
|
||||||
|
iv: dynamicSecretCfg.inputIV
|
||||||
|
})
|
||||||
|
) as object;
|
||||||
|
|
||||||
|
const revokeResponse = await selectedProvider
|
||||||
|
.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId)
|
||||||
|
.catch(async (err) => {
|
||||||
|
// only propogate this error if forced is false
|
||||||
|
if (!isForced) return { error: err as Error };
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((revokeResponse as { error?: Error })?.error) {
|
||||||
|
const { error } = revokeResponse as { error?: Error };
|
||||||
|
const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||||
|
status: DynamicSecretLeaseStatus.FailedDeletion,
|
||||||
|
statusDetails: error?.message?.slice(0, 255)
|
||||||
|
});
|
||||||
|
return deletedDynamicSecretLease;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
|
||||||
|
const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id);
|
||||||
|
return deletedDynamicSecretLease;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listLeases = async ({
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectSlug,
|
||||||
|
actorOrgId,
|
||||||
|
environmentSlug,
|
||||||
|
actorAuthMethod
|
||||||
|
}: TListDynamicSecretLeasesDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Read,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||||
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
|
||||||
|
const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
|
||||||
|
return dynamicSecretLeases;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLeaseDetails = async ({
|
||||||
|
projectSlug,
|
||||||
|
actorOrgId,
|
||||||
|
path,
|
||||||
|
environmentSlug,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
leaseId,
|
||||||
|
actorAuthMethod
|
||||||
|
}: TDetailsDynamicSecretLeaseDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Read,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId);
|
||||||
|
if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" });
|
||||||
|
|
||||||
|
return dynamicSecretLease;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
create,
|
||||||
|
listLeases,
|
||||||
|
revokeLease,
|
||||||
|
renewLease,
|
||||||
|
getLeaseDetails
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TDetailsDynamicSecretLeaseDTO = {
|
||||||
|
leaseId: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TListDynamicSecretLeasesDTO = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TDeleteDynamicSecretLeaseDTO = {
|
||||||
|
leaseId: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
isForced?: boolean;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TRenewDynamicSecretLeaseDTO = {
|
||||||
|
leaseId: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
ttl?: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
@@ -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<typeof dynamicSecretDALFactory>;
|
||||||
|
|
||||||
|
export const dynamicSecretDALFactory = (db: TDbClient) => {
|
||||||
|
const orm = ormify(db, TableName.DynamicSecret);
|
||||||
|
return orm;
|
||||||
|
};
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
|
||||||
|
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||||
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
|
import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal";
|
||||||
|
import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue";
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
|
import { TDynamicSecretDALFactory } from "./dynamic-secret-dal";
|
||||||
|
import {
|
||||||
|
DynamicSecretStatus,
|
||||||
|
TCreateDynamicSecretDTO,
|
||||||
|
TDeleteDynamicSecretDTO,
|
||||||
|
TDetailsDynamicSecretDTO,
|
||||||
|
TListDynamicSecretsDTO,
|
||||||
|
TUpdateDynamicSecretDTO
|
||||||
|
} from "./dynamic-secret-types";
|
||||||
|
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
|
||||||
|
|
||||||
|
type TDynamicSecretServiceFactoryDep = {
|
||||||
|
dynamicSecretDAL: TDynamicSecretDALFactory;
|
||||||
|
dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "find">;
|
||||||
|
dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>;
|
||||||
|
dynamicSecretQueueService: Pick<
|
||||||
|
TDynamicSecretLeaseQueueServiceFactory,
|
||||||
|
"pruneDynamicSecret" | "unsetLeaseRevocation"
|
||||||
|
>;
|
||||||
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>;
|
||||||
|
|
||||||
|
export const dynamicSecretServiceFactory = ({
|
||||||
|
dynamicSecretDAL,
|
||||||
|
dynamicSecretLeaseDAL,
|
||||||
|
licenseService,
|
||||||
|
folderDAL,
|
||||||
|
dynamicSecretProviders,
|
||||||
|
permissionService,
|
||||||
|
dynamicSecretQueueService,
|
||||||
|
projectDAL
|
||||||
|
}: TDynamicSecretServiceFactoryDep) => {
|
||||||
|
const create = async ({
|
||||||
|
path,
|
||||||
|
actor,
|
||||||
|
name,
|
||||||
|
actorId,
|
||||||
|
maxTTL,
|
||||||
|
provider,
|
||||||
|
environmentSlug,
|
||||||
|
projectSlug,
|
||||||
|
actorOrgId,
|
||||||
|
defaultTTL,
|
||||||
|
actorAuthMethod
|
||||||
|
}: TCreateDynamicSecretDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(actorOrgId);
|
||||||
|
if (!plan?.dynamicSecret) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to create dynamic secret due to plan restriction. Upgrade plan to create dynamic secret."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||||
|
if (existingDynamicSecret)
|
||||||
|
throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" });
|
||||||
|
|
||||||
|
const selectedProvider = dynamicSecretProviders[provider.type];
|
||||||
|
const inputs = await selectedProvider.validateProviderInputs(provider.inputs);
|
||||||
|
|
||||||
|
const isConnected = await selectedProvider.validateConnection(provider.inputs);
|
||||||
|
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
|
||||||
|
|
||||||
|
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs));
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.create({
|
||||||
|
type: provider.type,
|
||||||
|
version: 1,
|
||||||
|
inputIV: encryptedInput.iv,
|
||||||
|
inputTag: encryptedInput.tag,
|
||||||
|
inputCiphertext: encryptedInput.ciphertext,
|
||||||
|
algorithm: encryptedInput.algorithm,
|
||||||
|
keyEncoding: encryptedInput.encoding,
|
||||||
|
maxTTL,
|
||||||
|
defaultTTL,
|
||||||
|
folderId: folder.id,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return dynamicSecretCfg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateByName = async ({
|
||||||
|
name,
|
||||||
|
maxTTL,
|
||||||
|
defaultTTL,
|
||||||
|
inputs,
|
||||||
|
environmentSlug,
|
||||||
|
projectSlug,
|
||||||
|
path,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
newName,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod
|
||||||
|
}: TUpdateDynamicSecretDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(actorOrgId);
|
||||||
|
if (!plan?.dynamicSecret) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to update dynamic secret due to plan restriction. Upgrade plan to create dynamic secret."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||||
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
|
||||||
|
if (newName) {
|
||||||
|
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id });
|
||||||
|
if (existingDynamicSecret)
|
||||||
|
throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
||||||
|
const decryptedStoredInput = JSON.parse(
|
||||||
|
infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
|
||||||
|
ciphertext: dynamicSecretCfg.inputCiphertext,
|
||||||
|
tag: dynamicSecretCfg.inputTag,
|
||||||
|
iv: dynamicSecretCfg.inputIV
|
||||||
|
})
|
||||||
|
) as object;
|
||||||
|
const newInput = { ...decryptedStoredInput, ...(inputs || {}) };
|
||||||
|
const updatedInput = await selectedProvider.validateProviderInputs(newInput);
|
||||||
|
|
||||||
|
const isConnected = await selectedProvider.validateConnection(newInput);
|
||||||
|
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
|
||||||
|
|
||||||
|
const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(updatedInput));
|
||||||
|
const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, {
|
||||||
|
inputIV: encryptedInput.iv,
|
||||||
|
inputTag: encryptedInput.tag,
|
||||||
|
inputCiphertext: encryptedInput.ciphertext,
|
||||||
|
algorithm: encryptedInput.algorithm,
|
||||||
|
keyEncoding: encryptedInput.encoding,
|
||||||
|
maxTTL,
|
||||||
|
defaultTTL,
|
||||||
|
name: newName ?? name,
|
||||||
|
status: null,
|
||||||
|
statusDetails: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDynamicCfg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteByName = async ({
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
projectSlug,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
environmentSlug,
|
||||||
|
isForced
|
||||||
|
}: TDeleteDynamicSecretDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||||
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
|
||||||
|
const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
|
||||||
|
// when not forced we check with the external system to first remove the things
|
||||||
|
// we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system
|
||||||
|
// this allows user to clean up it from infisical
|
||||||
|
if (isForced) {
|
||||||
|
// clear all queues for lease revocations
|
||||||
|
await Promise.all(leases.map(({ id: leaseId }) => dynamicSecretQueueService.unsetLeaseRevocation(leaseId)));
|
||||||
|
|
||||||
|
const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id);
|
||||||
|
return deletedDynamicSecretCfg;
|
||||||
|
}
|
||||||
|
// if leases exist we should flag it as deleting and then remove leases in background
|
||||||
|
// then delete the main one
|
||||||
|
if (leases.length) {
|
||||||
|
const updatedDynamicSecretCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, {
|
||||||
|
status: DynamicSecretStatus.Deleting
|
||||||
|
});
|
||||||
|
await dynamicSecretQueueService.pruneDynamicSecret(updatedDynamicSecretCfg.id);
|
||||||
|
return updatedDynamicSecretCfg;
|
||||||
|
}
|
||||||
|
// if no leases just delete the config
|
||||||
|
const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id);
|
||||||
|
return deletedDynamicSecretCfg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetails = async ({
|
||||||
|
name,
|
||||||
|
projectSlug,
|
||||||
|
path,
|
||||||
|
environmentSlug,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actorId,
|
||||||
|
actor
|
||||||
|
}: TDetailsDynamicSecretDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||||
|
if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" });
|
||||||
|
const decryptedStoredInput = JSON.parse(
|
||||||
|
infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding,
|
||||||
|
ciphertext: dynamicSecretCfg.inputCiphertext,
|
||||||
|
tag: dynamicSecretCfg.inputTag,
|
||||||
|
iv: dynamicSecretCfg.inputIV
|
||||||
|
})
|
||||||
|
) as object;
|
||||||
|
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
|
||||||
|
const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object;
|
||||||
|
return { ...dynamicSecretCfg, inputs: providerInputs };
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = async ({
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
projectSlug,
|
||||||
|
path,
|
||||||
|
environmentSlug
|
||||||
|
}: TListDynamicSecretsDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const projectId = project.id;
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||||
|
);
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||||
|
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||||
|
|
||||||
|
const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id });
|
||||||
|
return dynamicSecretCfg;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
create,
|
||||||
|
updateByName,
|
||||||
|
deleteByName,
|
||||||
|
getDetails,
|
||||||
|
list
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<typeof DynamicSecretProviderSchema>;
|
||||||
|
export type TCreateDynamicSecretDTO = {
|
||||||
|
provider: TProvider;
|
||||||
|
defaultTTL: string;
|
||||||
|
maxTTL?: string | null;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
name: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TUpdateDynamicSecretDTO = {
|
||||||
|
name: string;
|
||||||
|
newName?: string;
|
||||||
|
defaultTTL?: string;
|
||||||
|
maxTTL?: string | null;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
inputs?: TProvider["inputs"];
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TDeleteDynamicSecretDTO = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
isForced?: boolean;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TDetailsDynamicSecretDTO = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TListDynamicSecretsDTO = {
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { DynamicSecretProviders } from "./models";
|
||||||
|
import { SqlDatabaseProvider } from "./sql-database";
|
||||||
|
|
||||||
|
export const buildDynamicSecretProviders = () => ({
|
||||||
|
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider()
|
||||||
|
});
|
||||||
@@ -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<boolean>;
|
||||||
|
validateProviderInputs: (inputs: object) => Promise<unknown>;
|
||||||
|
revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>;
|
||||||
|
renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import handlebars from "handlebars";
|
||||||
|
import knex from "knex";
|
||||||
|
import { customAlphabet } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { getDbConnectionHost } from "@app/lib/knex";
|
||||||
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
|
||||||
|
import { DynamicSecretSqlDBSchema, TDynamicProviderFns } from "./models";
|
||||||
|
|
||||||
|
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||||
|
|
||||||
|
const generatePassword = (size?: number) => {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||||
|
return customAlphabet(charset, 48)(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SqlDatabaseProvider = (): TDynamicProviderFns => {
|
||||||
|
const validateProviderInputs = async (inputs: unknown) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI);
|
||||||
|
|
||||||
|
const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs);
|
||||||
|
if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1" || dbHost === providerInputs.host)
|
||||||
|
throw new BadRequestError({ message: "Invalid db host" });
|
||||||
|
return providerInputs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClient = async (providerInputs: z.infer<typeof DynamicSecretSqlDBSchema>) => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -328,7 +328,7 @@ export const projectMembershipServiceFactory = ({
|
|||||||
);
|
);
|
||||||
const hasCustomRole = Boolean(customInputRoles.length);
|
const hasCustomRole = Boolean(customInputRoles.length);
|
||||||
if (hasCustomRole) {
|
if (hasCustomRole) {
|
||||||
const plan = await licenseService.getPlan(actorOrgId as string);
|
const plan = await licenseService.getPlan(actorOrgId);
|
||||||
if (!plan?.rbac)
|
if (!plan?.rbac)
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
|
message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
.infisical.json
|
.infisical.json
|
||||||
dist/
|
dist/
|
||||||
|
agent-config.test.yaml
|
||||||
|
|||||||
@@ -535,3 +535,23 @@ func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Reques
|
|||||||
|
|
||||||
return getRawSecretsV3Response, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -500,6 +500,28 @@ type UniversalAuthRefreshResponse struct {
|
|||||||
AccessTokenMaxTTL int `json:"accessTokenMaxTTL"`
|
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 {
|
type GetRawSecretsV3Request struct {
|
||||||
Environment string `json:"environment"`
|
Environment string `json:"environment"`
|
||||||
WorkspaceId string `json:"workspaceId"`
|
WorkspaceId string `json:"workspaceId"`
|
||||||
|
|||||||
+206
-47
@@ -14,6 +14,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path"
|
"path"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -33,6 +34,9 @@ import (
|
|||||||
|
|
||||||
const DEFAULT_INFISICAL_CLOUD_URL = "https://app.infisical.com"
|
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 {
|
type Config struct {
|
||||||
Infisical InfisicalConfig `yaml:"infisical"`
|
Infisical InfisicalConfig `yaml:"infisical"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
@@ -84,6 +88,115 @@ type Template struct {
|
|||||||
} `yaml:"config"`
|
} `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) {
|
func ReadFile(filePath string) ([]byte, error) {
|
||||||
return ioutil.ReadFile(filePath)
|
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
|
// custom template function to fetch secrets from Infisical
|
||||||
secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag)
|
secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag)
|
||||||
|
dynamicSecretFunction := dynamicSecretTemplateFunction(accessToken, dynamicSecretManager, templateId)
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"secret": secretFunction,
|
"secret": secretFunction,
|
||||||
|
"dynamic_secret": dynamicSecretFunction,
|
||||||
}
|
}
|
||||||
|
|
||||||
templateName := path.Base(templatePath)
|
templateName := path.Base(templatePath)
|
||||||
|
|
||||||
tmpl, err := template.New(templateName).Funcs(funcs).ParseFiles(templatePath)
|
tmpl, err := template.New(templateName).Funcs(funcs).ParseFiles(templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -256,7 +397,7 @@ func ProcessTemplate(templatePath string, data interface{}, accessToken string,
|
|||||||
return &buf, nil
|
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
|
// custom template function to fetch secrets from Infisical
|
||||||
decoded, err := base64.StdEncoding.DecodeString(encodedTemplate)
|
decoded, err := base64.StdEncoding.DecodeString(encodedTemplate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,8 +407,10 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken
|
|||||||
templateString := string(decoded)
|
templateString := string(decoded)
|
||||||
|
|
||||||
secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) // TODO: Fix this
|
secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) // TODO: Fix this
|
||||||
|
dynamicSecretFunction := dynamicSecretTemplateFunction(accessToken, dynamicSecretLeaser, templateId)
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"secret": secretFunction,
|
"secret": secretFunction,
|
||||||
|
"dynamic_secret": dynamicSecretFunction,
|
||||||
}
|
}
|
||||||
|
|
||||||
templateName := "base64Template"
|
templateName := "base64Template"
|
||||||
@@ -285,7 +428,7 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken
|
|||||||
return &buf, nil
|
return &buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenManager struct {
|
type AgentManager struct {
|
||||||
accessToken string
|
accessToken string
|
||||||
accessTokenTTL time.Duration
|
accessTokenTTL time.Duration
|
||||||
accessTokenMaxTTL time.Duration
|
accessTokenMaxTTL time.Duration
|
||||||
@@ -294,6 +437,7 @@ type TokenManager struct {
|
|||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
filePaths []Sink // Store file paths if needed
|
filePaths []Sink // Store file paths if needed
|
||||||
templates []Template
|
templates []Template
|
||||||
|
dynamicSecretLeases *DynamicSecretLeaseManager
|
||||||
clientIdPath string
|
clientIdPath string
|
||||||
clientSecretPath string
|
clientSecretPath string
|
||||||
newAccessTokenNotificationChan chan bool
|
newAccessTokenNotificationChan chan bool
|
||||||
@@ -302,8 +446,8 @@ type TokenManager struct {
|
|||||||
exitAfterAuth bool
|
exitAfterAuth bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTokenManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *TokenManager {
|
func NewAgentManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *AgentManager {
|
||||||
return &TokenManager{
|
return &AgentManager{
|
||||||
filePaths: fileDeposits,
|
filePaths: fileDeposits,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
clientIdPath: clientIdPath,
|
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()
|
tm.mutex.Lock()
|
||||||
defer tm.mutex.Unlock()
|
defer tm.mutex.Unlock()
|
||||||
|
|
||||||
@@ -326,7 +470,7 @@ func (tm *TokenManager) SetToken(token string, accessTokenTTL time.Duration, acc
|
|||||||
tm.newAccessTokenNotificationChan <- true
|
tm.newAccessTokenNotificationChan <- true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tm *TokenManager) GetToken() string {
|
func (tm *AgentManager) GetToken() string {
|
||||||
tm.mutex.Lock()
|
tm.mutex.Lock()
|
||||||
defer tm.mutex.Unlock()
|
defer tm.mutex.Unlock()
|
||||||
|
|
||||||
@@ -334,7 +478,7 @@ func (tm *TokenManager) GetToken() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetches a new access token using client credentials
|
// 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")
|
clientID := os.Getenv("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID")
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientIDAsByte, err := ReadFile(tm.clientIdPath)
|
clientIDAsByte, err := ReadFile(tm.clientIdPath)
|
||||||
@@ -384,7 +528,7 @@ func (tm *TokenManager) FetchNewAccessToken() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refreshes the existing access token
|
// Refreshes the existing access token
|
||||||
func (tm *TokenManager) RefreshAccessToken() error {
|
func (tm *AgentManager) RefreshAccessToken() error {
|
||||||
httpClient := resty.New()
|
httpClient := resty.New()
|
||||||
httpClient.SetRetryCount(10000).
|
httpClient.SetRetryCount(10000).
|
||||||
SetRetryMaxWaitTime(20 * time.Second).
|
SetRetryMaxWaitTime(20 * time.Second).
|
||||||
@@ -405,7 +549,7 @@ func (tm *TokenManager) RefreshAccessToken() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tm *TokenManager) ManageTokenLifecycle() {
|
func (tm *AgentManager) ManageTokenLifecycle() {
|
||||||
for {
|
for {
|
||||||
accessTokenMaxTTLExpiresInTime := tm.accessTokenFetchedTime.Add(tm.accessTokenMaxTTL - (5 * time.Second))
|
accessTokenMaxTTLExpiresInTime := tm.accessTokenFetchedTime.Add(tm.accessTokenMaxTTL - (5 * time.Second))
|
||||||
accessTokenRefreshedTime := tm.accessTokenRefreshedTime
|
accessTokenRefreshedTime := tm.accessTokenRefreshedTime
|
||||||
@@ -473,7 +617,7 @@ func (tm *TokenManager) ManageTokenLifecycle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tm *TokenManager) WriteTokenToFiles() {
|
func (tm *AgentManager) WriteTokenToFiles() {
|
||||||
token := tm.GetToken()
|
token := tm.GetToken()
|
||||||
for _, sinkFile := range tm.filePaths {
|
for _, sinkFile := range tm.filePaths {
|
||||||
if sinkFile.Type == "file" {
|
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 {
|
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)
|
log.Error().Msgf("template engine: unable to write secrets to path because %s. Will try again on next cycle", err)
|
||||||
return
|
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)
|
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)
|
pollingInterval := time.Duration(5 * time.Minute)
|
||||||
|
|
||||||
@@ -523,47 +667,61 @@ func (tm *TokenManager) MonitorSecretChanges(secretTemplate Template, sigChan ch
|
|||||||
execCommand := secretTemplate.Config.Execute.Command
|
execCommand := secretTemplate.Config.Execute.Command
|
||||||
|
|
||||||
for {
|
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
|
if err != nil {
|
||||||
var err error
|
log.Error().Msgf("unable to process template because %v", err)
|
||||||
|
} else {
|
||||||
|
if (existingEtag != currentEtag) || firstRun {
|
||||||
|
|
||||||
if secretTemplate.SourcePath != "" {
|
tm.WriteTemplateToFile(processedTemplate, &secretTemplate)
|
||||||
processedTemplate, err = ProcessTemplate(secretTemplate.SourcePath, nil, token, existingEtag, ¤tEtag)
|
existingEtag = currentEtag
|
||||||
} else {
|
|
||||||
processedTemplate, err = ProcessBase64Template(secretTemplate.Base64TemplateContent, nil, token, existingEtag, ¤tEtag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if !firstRun && execCommand != "" {
|
||||||
log.Error().Msgf("unable to process template because %v", err)
|
log.Info().Msgf("executing command: %s", execCommand)
|
||||||
} else {
|
err := ExecuteCommandWithTimeout(execCommand, execTimeout)
|
||||||
if (existingEtag != currentEtag) || firstRun {
|
|
||||||
|
|
||||||
tm.WriteTemplateToFile(processedTemplate, &secretTemplate)
|
if err != nil {
|
||||||
existingEtag = currentEtag
|
log.Error().Msgf("unable to execute command because %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if !firstRun && execCommand != "" {
|
}
|
||||||
log.Info().Msgf("executing command: %s", execCommand)
|
if firstRun {
|
||||||
err := ExecuteCommandWithTimeout(execCommand, execTimeout)
|
firstRun = false
|
||||||
|
}
|
||||||
if err != nil {
|
|
||||||
log.Error().Msgf("unable to execute command because %v", err)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
time.Sleep(waitTime)
|
||||||
firstRun = false
|
} 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)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
filePaths := agentConfig.Sinks
|
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()
|
go tm.ManageTokenLifecycle()
|
||||||
|
|
||||||
for i, template := range agentConfig.Templates {
|
for i, template := range agentConfig.Templates {
|
||||||
log.Info().Msgf("template engine started for template %v...", i+1)
|
log.Info().Msgf("template engine started for template %v...", i+1)
|
||||||
go tm.MonitorSecretChanges(template, sigChan)
|
go tm.MonitorSecretChanges(template, i, sigChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type UserCredentials struct {
|
type UserCredentials struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PrivateKey string `json:"privateKey"`
|
PrivateKey string `json:"privateKey"`
|
||||||
@@ -40,6 +42,23 @@ type PlaintextSecretResult struct {
|
|||||||
Etag string
|
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 {
|
type SingleFolder struct {
|
||||||
ID string `json:"_id"`
|
ID string `json:"_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -195,6 +195,31 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin
|
|||||||
}, nil
|
}, 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) {
|
func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedSecretV3) ([]models.SingleEnvironmentVariable, error) {
|
||||||
if importedSecrets == nil {
|
if importedSecrets == nil {
|
||||||
return secrets, nil
|
return secrets, nil
|
||||||
|
|||||||
Generated
+16
-15
@@ -4,6 +4,7 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.5.0",
|
"@casl/ability": "^6.5.0",
|
||||||
"@casl/react": "^3.1.0",
|
"@casl/react": "^3.1.0",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"next": "^12.3.4",
|
"next": "^12.3.4",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"picomatch": "^2.3.1",
|
"picomatch": "^2.3.1",
|
||||||
@@ -11455,6 +11457,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/debug/node_modules/ms": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
|
||||||
@@ -13759,9 +13766,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.5",
|
"version": "1.15.6",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
|
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -15126,9 +15133,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip": {
|
"node_modules/ip": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||||
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
|
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
@@ -17518,9 +17525,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
"node_modules/multipipe": {
|
"node_modules/multipipe": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@@ -21287,12 +21294,6 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/serialize-javascript": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"next": "^12.3.4",
|
"next": "^12.3.4",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"picomatch": "^2.3.1",
|
"picomatch": "^2.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { FormLabel, Tooltip } from "../v2";
|
||||||
|
|
||||||
|
// To give users example of possible values of TTL
|
||||||
|
export const TtlFormLabel = ({ label }: { label: string }) => (
|
||||||
|
<div>
|
||||||
|
<FormLabel
|
||||||
|
label={label}
|
||||||
|
icon={
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<span>
|
||||||
|
1m, 2h, 3d.{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-700"
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faQuestionCircle}
|
||||||
|
size="sm"
|
||||||
|
className="relative bottom-1 right-1"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { TtlFormLabel } from "./TtlFormLabel";
|
||||||
@@ -27,7 +27,7 @@ export const AccordionTrigger = forwardRef<
|
|||||||
<AccordionPrimitive.Header className="flex">
|
<AccordionPrimitive.Header className="flex">
|
||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"group flex h-11 flex-1 items-center justify-between py-2 px-4 outline-none hover:text-primary data-[state=open]:text-primary ",
|
"group flex h-11 flex-1 items-center justify-between py-2 px-4 outline-none hover:text-primary data-[state=open]:text-primary",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export type FormLabelProps = {
|
export type FormLabelProps = {
|
||||||
id?: string;
|
id?: string;
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
|
isOptional?:boolean;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormLabel = ({ id, label, isRequired, icon, className }: FormLabelProps) => (
|
export const FormLabel = ({ id, label, isRequired, icon, className,isOptional }: FormLabelProps) => (
|
||||||
<Label.Root
|
<Label.Root
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"mb-0.5 ml-1 block flex items-center text-sm font-normal text-mineshaft-400",
|
"mb-0.5 ml-1 flex items-center text-sm font-normal text-mineshaft-400",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{isRequired && <span className="ml-1 text-red">*</span>}
|
{isRequired && <span className="ml-1 text-red">*</span>}
|
||||||
|
{isOptional && <span className="ml-1 text-gray-500 italic text-xs">- Optional</span>}
|
||||||
{icon && (
|
{icon && (
|
||||||
<span className="ml-2 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
|
<span className="ml-2 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
|
||||||
{icon}
|
{icon}
|
||||||
@@ -54,6 +56,7 @@ export const FormHelperText = ({ isError, text }: FormHelperTextProps) => (
|
|||||||
export type FormControlProps = {
|
export type FormControlProps = {
|
||||||
id?: string;
|
id?: string;
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
|
isOptional?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
helperText?: ReactNode;
|
helperText?: ReactNode;
|
||||||
@@ -66,6 +69,7 @@ export type FormControlProps = {
|
|||||||
export const FormControl = ({
|
export const FormControl = ({
|
||||||
children,
|
children,
|
||||||
isRequired,
|
isRequired,
|
||||||
|
isOptional,
|
||||||
label,
|
label,
|
||||||
helperText,
|
helperText,
|
||||||
errorText,
|
errorText,
|
||||||
@@ -77,7 +81,13 @@ export const FormControl = ({
|
|||||||
return (
|
return (
|
||||||
<div className={twMerge("mb-4", className)}>
|
<div className={twMerge("mb-4", className)}>
|
||||||
{typeof label === "string" ? (
|
{typeof label === "string" ? (
|
||||||
<FormLabel label={label} isRequired={isRequired} id={id} icon={icon} />
|
<FormLabel
|
||||||
|
label={label}
|
||||||
|
isOptional={isOptional}
|
||||||
|
isRequired={isRequired}
|
||||||
|
id={id}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
label
|
label
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useCreateDynamicSecret, useDeleteDynamicSecret, useUpdateDynamicSecret } from "./mutation";
|
||||||
|
export { useGetDynamicSecretDetails,useGetDynamicSecrets } from "./queries";
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { dynamicSecretKeys } from "./queries";
|
||||||
|
import {
|
||||||
|
TCreateDynamicSecretDTO,
|
||||||
|
TDeleteDynamicSecretDTO,
|
||||||
|
TDynamicSecret,
|
||||||
|
TUpdateDynamicSecretDTO
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const useCreateDynamicSecret = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TCreateDynamicSecretDTO>({
|
||||||
|
mutationFn: async (dto) => {
|
||||||
|
const { data } = await apiRequest.post<{ dynamicSecret: TDynamicSecret }>(
|
||||||
|
"/api/v1/dynamic-secrets",
|
||||||
|
dto
|
||||||
|
);
|
||||||
|
return data.dynamicSecret;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateDynamicSecret = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TUpdateDynamicSecretDTO>({
|
||||||
|
mutationFn: async (dto) => {
|
||||||
|
const { data } = await apiRequest.patch<{ dynamicSecret: TDynamicSecret }>(
|
||||||
|
`/api/v1/dynamic-secrets/${dto.name}`,
|
||||||
|
dto
|
||||||
|
);
|
||||||
|
return data.dynamicSecret;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteDynamicSecret = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TDeleteDynamicSecretDTO>({
|
||||||
|
mutationFn: async (dto) => {
|
||||||
|
const { data } = await apiRequest.delete<{ dynamicSecret: TDynamicSecret }>(
|
||||||
|
`/api/v1/dynamic-secrets/${dto.name}`,
|
||||||
|
{ data: dto }
|
||||||
|
);
|
||||||
|
return data.dynamicSecret;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { TDetailsDynamicSecretDTO, TDynamicSecret, TListDynamicSecretDTO } from "./types";
|
||||||
|
|
||||||
|
export const dynamicSecretKeys = {
|
||||||
|
list: ({
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug,
|
||||||
|
path
|
||||||
|
}: Pick<TListDynamicSecretDTO, "path" | "environmentSlug" | "projectSlug">) =>
|
||||||
|
[{ projectSlug, environmentSlug, path }, "dynamic-secrets"] as const,
|
||||||
|
details: ({ path, environmentSlug, projectSlug, name }: TDetailsDynamicSecretDTO) =>
|
||||||
|
[{ projectSlug, path, environmentSlug, name }, "dynamic-secret-details"] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetDynamicSecrets = ({ projectSlug, environmentSlug, path }: TListDynamicSecretDTO) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: dynamicSecretKeys.list({ path, environmentSlug, projectSlug }),
|
||||||
|
enabled: Boolean(projectSlug && environmentSlug && path),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<{ dynamicSecrets: TDynamicSecret[] }>(
|
||||||
|
"/api/v1/dynamic-secrets",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.dynamicSecrets;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetDynamicSecretDetails = ({
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug,
|
||||||
|
path,
|
||||||
|
name
|
||||||
|
}: TDetailsDynamicSecretDTO) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: dynamicSecretKeys.details({ path, environmentSlug, projectSlug, name }),
|
||||||
|
enabled: Boolean(projectSlug && environmentSlug && path && name),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<{
|
||||||
|
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||||
|
}>(`/api/v1/dynamic-secrets/${name}`, {
|
||||||
|
params: {
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.dynamicSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export enum DynamicSecretStatus {
|
||||||
|
Deleting = "Revocation in process",
|
||||||
|
FailedDeletion = "Failed to delete"
|
||||||
|
}
|
||||||
|
// TODO(akhilmhdh): When we switch to monorepo all the server api ts will be in a shared repo
|
||||||
|
export type TDynamicSecret = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: DynamicSecretProviders;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
defaultTTL: string;
|
||||||
|
status?: DynamicSecretStatus;
|
||||||
|
statusDetails?: string;
|
||||||
|
maxTTL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum DynamicSecretProviders {
|
||||||
|
SqlDatabase = "sql-database"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SqlProviders {
|
||||||
|
Postgres = "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TDynamicSecretProvider = {
|
||||||
|
type: DynamicSecretProviders;
|
||||||
|
inputs: {
|
||||||
|
client: SqlProviders;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
creationStatement: string;
|
||||||
|
revocationStatement: string;
|
||||||
|
renewStatement: string;
|
||||||
|
ca?: string | undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateDynamicSecretDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
provider: TDynamicSecretProvider;
|
||||||
|
defaultTTL: string;
|
||||||
|
maxTTL?: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateDynamicSecretDTO = {
|
||||||
|
name: string;
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
data: {
|
||||||
|
newName?: string;
|
||||||
|
defaultTTL?: string;
|
||||||
|
maxTTL?: string | null;
|
||||||
|
inputs?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TListDynamicSecretDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDeleteDynamicSecretDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
name: string;
|
||||||
|
isForced?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDetailsDynamicSecretDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
useCreateDynamicSecretLease,
|
||||||
|
useRenewDynamicSecretLease,
|
||||||
|
useRevokeDynamicSecretLease
|
||||||
|
} from "./mutation";
|
||||||
|
export { useGetDynamicSecretLeases } from "./queries";
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { dynamicSecretLeaseKeys } from "./queries";
|
||||||
|
import {
|
||||||
|
TCreateDynamicSecretLeaseDTO,
|
||||||
|
TDynamicSecretLease,
|
||||||
|
TRenewDynamicSecretLeaseDTO,
|
||||||
|
TRevokeDynamicSecretLeaseDTO
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const useCreateDynamicSecretLease = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
{ lease: TDynamicSecretLease; data: unknown },
|
||||||
|
{},
|
||||||
|
TCreateDynamicSecretLeaseDTO
|
||||||
|
>({
|
||||||
|
mutationFn: async (dto) => {
|
||||||
|
const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>(
|
||||||
|
"/api/v1/dynamic-secrets/leases",
|
||||||
|
dto
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { path, environmentSlug, projectSlug, dynamicSecretName }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
dynamicSecretLeaseKeys.list({ path, projectSlug, environmentSlug, dynamicSecretName })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRenewDynamicSecretLease = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TRenewDynamicSecretLeaseDTO>({
|
||||||
|
mutationFn: async (dto) => {
|
||||||
|
const { data } = await apiRequest.post<{ lease: TDynamicSecretLease }>(
|
||||||
|
`/api/v1/dynamic-secrets/leases/${dto.leaseId}/renew`,
|
||||||
|
dto
|
||||||
|
);
|
||||||
|
return data.lease;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { path, environmentSlug, projectSlug, dynamicSecretName }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
dynamicSecretLeaseKeys.list({ path, projectSlug, environmentSlug, dynamicSecretName })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRevokeDynamicSecretLease = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TRevokeDynamicSecretLeaseDTO>({
|
||||||
|
mutationFn: async (dto) => {
|
||||||
|
const { data } = await apiRequest.delete<{ lease: TDynamicSecretLease }>(
|
||||||
|
`/api/v1/dynamic-secrets/leases/${dto.leaseId}`,
|
||||||
|
{ data: dto }
|
||||||
|
);
|
||||||
|
return data.lease;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { path, environmentSlug, projectSlug, dynamicSecretName }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
dynamicSecretLeaseKeys.list({ path, projectSlug, environmentSlug, dynamicSecretName })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { TDynamicSecretLease, TListDynamicSecretLeaseDTO } from "./types";
|
||||||
|
|
||||||
|
export const dynamicSecretLeaseKeys = {
|
||||||
|
list: ({ projectSlug, environmentSlug, path, dynamicSecretName }: TListDynamicSecretLeaseDTO) =>
|
||||||
|
[{ projectSlug, environmentSlug, path, dynamicSecretName }, "dynamic-secret-leases"] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetDynamicSecretLeases = ({
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug,
|
||||||
|
path,
|
||||||
|
dynamicSecretName,
|
||||||
|
enabled = true
|
||||||
|
}: TListDynamicSecretLeaseDTO) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: dynamicSecretLeaseKeys.list({ path, environmentSlug, projectSlug, dynamicSecretName }),
|
||||||
|
enabled: Boolean(projectSlug && environmentSlug && path && dynamicSecretName && enabled),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<{ leases: TDynamicSecretLease[] }>(
|
||||||
|
`/api/v1/dynamic-secrets/${dynamicSecretName}/leases`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.leases;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export enum DynamicSecretLeaseStatus {
|
||||||
|
FailedDeletion = "Failed to delete"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TDynamicSecretLease = {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
expireAt: string;
|
||||||
|
dynamicSecretId: string;
|
||||||
|
status?: DynamicSecretLeaseStatus;
|
||||||
|
statusDetails?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateDynamicSecretLeaseDTO = {
|
||||||
|
dynamicSecretName: string;
|
||||||
|
projectSlug: string;
|
||||||
|
ttl?: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TRenewDynamicSecretLeaseDTO = {
|
||||||
|
leaseId: string;
|
||||||
|
dynamicSecretName: string;
|
||||||
|
ttl?: string;
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TListDynamicSecretLeaseDTO = {
|
||||||
|
dynamicSecretName: string;
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TRevokeDynamicSecretLeaseDTO = {
|
||||||
|
leaseId: string;
|
||||||
|
dynamicSecretName: string;
|
||||||
|
projectSlug: string;
|
||||||
|
path: string;
|
||||||
|
environmentSlug: string;
|
||||||
|
isForced?: boolean;
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ export * from "./apiKeys";
|
|||||||
export * from "./auditLogs";
|
export * from "./auditLogs";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./bots";
|
export * from "./bots";
|
||||||
|
export * from "./dynamicSecret";
|
||||||
|
export * from "./dynamicSecretLease";
|
||||||
export * from "./identities";
|
export * from "./identities";
|
||||||
export * from "./incidentContacts";
|
export * from "./incidentContacts";
|
||||||
export * from "./integrationAuth";
|
export * from "./integrationAuth";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SubscriptionPlan = {
|
|||||||
membersUsed: number;
|
membersUsed: number;
|
||||||
memberLimit: number;
|
memberLimit: number;
|
||||||
auditLogs: boolean;
|
auditLogs: boolean;
|
||||||
|
dynamicSecret: boolean;
|
||||||
auditLogsRetentionDays: number;
|
auditLogsRetentionDays: number;
|
||||||
customAlerts: boolean;
|
customAlerts: boolean;
|
||||||
customRateLimits: boolean;
|
customRateLimits: boolean;
|
||||||
@@ -21,14 +22,14 @@ export type SubscriptionPlan = {
|
|||||||
scim: boolean;
|
scim: boolean;
|
||||||
ldap: boolean;
|
ldap: boolean;
|
||||||
status:
|
status:
|
||||||
| "incomplete"
|
| "incomplete"
|
||||||
| "incomplete_expired"
|
| "incomplete_expired"
|
||||||
| "trialing"
|
| "trialing"
|
||||||
| "active"
|
| "active"
|
||||||
| "past_due"
|
| "past_due"
|
||||||
| "canceled"
|
| "canceled"
|
||||||
| "unpaid"
|
| "unpaid"
|
||||||
| null;
|
| null;
|
||||||
trial_end: number | null;
|
trial_end: number | null;
|
||||||
has_used_trial: boolean;
|
has_used_trial: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { useLeaveConfirm } from "./useLeaveConfirm";
|
|||||||
export { usePersistentState } from "./usePersistentState";
|
export { usePersistentState } from "./usePersistentState";
|
||||||
export { usePopUp } from "./usePopUp";
|
export { usePopUp } from "./usePopUp";
|
||||||
export { useSyntaxHighlight } from "./useSyntaxHighlight";
|
export { useSyntaxHighlight } from "./useSyntaxHighlight";
|
||||||
|
export { useTimedReset } from "./useTimedReset";
|
||||||
export { useToggle } from "./useToggle";
|
export { useToggle } from "./useToggle";
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Props<T extends unknown> = {
|
||||||
|
initialState: T;
|
||||||
|
delay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this hook is used when you need to reset the state to previous one after a particular time
|
||||||
|
// usecase#1: To make copy to copied and back to copy in clipboard operation
|
||||||
|
export const useTimedReset = <T extends string | number | boolean>({
|
||||||
|
delay = 2000,
|
||||||
|
initialState
|
||||||
|
}: Props<T>): [T, boolean, Dispatch<SetStateAction<T>>] => {
|
||||||
|
const [state, setState] = useState<T>(initialState);
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
if (state !== initialState) {
|
||||||
|
timer = setTimeout(() => setState(initialState), delay);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// state, isChaning, setState
|
||||||
|
return [state, state !== initialState, setState];
|
||||||
|
};
|
||||||
@@ -31,6 +31,7 @@ const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
|||||||
export enum PopUpNames {
|
export enum PopUpNames {
|
||||||
CreateSecretForm = "create-secret-form"
|
CreateSecretForm = "create-secret-form"
|
||||||
}
|
}
|
||||||
|
|
||||||
type PopUpState = {
|
type PopUpState = {
|
||||||
popUp: Record<string, { isOpen: boolean; data?: any }>;
|
popUp: Record<string, { isOpen: boolean; data?: any }>;
|
||||||
popUpActions: {
|
popUpActions: {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import {
|
import {
|
||||||
|
useGetDynamicSecrets,
|
||||||
useGetImportedSecretsSingleEnv,
|
useGetImportedSecretsSingleEnv,
|
||||||
useGetProjectFolders,
|
useGetProjectFolders,
|
||||||
useGetProjectSecrets,
|
useGetProjectSecrets,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
import { ProjectIndexSecretsSection } from "../SecretOverviewPage/components/ProjectIndexSecretsSection";
|
import { ProjectIndexSecretsSection } from "../SecretOverviewPage/components/ProjectIndexSecretsSection";
|
||||||
import { ActionBar } from "./components/ActionBar";
|
import { ActionBar } from "./components/ActionBar";
|
||||||
import { CreateSecretForm } from "./components/CreateSecretForm";
|
import { CreateSecretForm } from "./components/CreateSecretForm";
|
||||||
|
import { DynamicSecretListView } from "./components/DynamicSecretListView";
|
||||||
import { FolderListView } from "./components/FolderListView";
|
import { FolderListView } from "./components/FolderListView";
|
||||||
import { PitDrawer } from "./components/PitDrawer";
|
import { PitDrawer } from "./components/PitDrawer";
|
||||||
import { SecretDropzone } from "./components/SecretDropzone";
|
import { SecretDropzone } from "./components/SecretDropzone";
|
||||||
@@ -67,6 +69,7 @@ export const SecretMainPage = () => {
|
|||||||
// env slug
|
// env slug
|
||||||
const environment = router.query.env as string;
|
const environment = router.query.env as string;
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
const secretPath = (router.query.secretPath as string) || "/";
|
const secretPath = (router.query.secretPath as string) || "/";
|
||||||
const canReadSecret = permission.can(
|
const canReadSecret = permission.can(
|
||||||
ProjectPermissionActions.Read,
|
ProjectPermissionActions.Read,
|
||||||
@@ -103,12 +106,14 @@ export const SecretMainPage = () => {
|
|||||||
enabled: canReadSecret
|
enabled: canReadSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetch folders
|
// fetch folders
|
||||||
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
|
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
|
||||||
projectId: workspaceId,
|
projectId: workspaceId,
|
||||||
environment,
|
environment,
|
||||||
path: secretPath
|
path: secretPath
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetch secret imports
|
// fetch secret imports
|
||||||
const {
|
const {
|
||||||
data: secretImports,
|
data: secretImports,
|
||||||
@@ -133,6 +138,13 @@ export const SecretMainPage = () => {
|
|||||||
enabled: canReadSecret
|
enabled: canReadSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment,
|
||||||
|
path: secretPath
|
||||||
|
});
|
||||||
|
|
||||||
// fech tags
|
// fech tags
|
||||||
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
|
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
|
||||||
|
|
||||||
@@ -163,7 +175,9 @@ export const SecretMainPage = () => {
|
|||||||
isPaused: !canDoReadRollback
|
isPaused: !canDoReadRollback
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.length);
|
const isNotEmtpy = Boolean(
|
||||||
|
secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length
|
||||||
|
);
|
||||||
|
|
||||||
const handleSortToggle = () =>
|
const handleSortToggle = () =>
|
||||||
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));
|
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));
|
||||||
@@ -213,7 +227,8 @@ export const SecretMainPage = () => {
|
|||||||
|
|
||||||
// loading screen when u have permission
|
// loading screen when u have permission
|
||||||
const loadingOnAccess =
|
const loadingOnAccess =
|
||||||
canReadSecret && (isSecretsLoading || isSecretImportsLoading || isFoldersLoading);
|
canReadSecret &&
|
||||||
|
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading);
|
||||||
// loading screen when you don't have permission but as folder's is viewable need to wait for that
|
// loading screen when you don't have permission but as folder's is viewable need to wait for that
|
||||||
const loadingOnDenied = !canReadSecret && isFoldersLoading;
|
const loadingOnDenied = !canReadSecret && isFoldersLoading;
|
||||||
if (loadingOnAccess || loadingOnDenied) {
|
if (loadingOnAccess || loadingOnDenied) {
|
||||||
@@ -244,6 +259,7 @@ export const SecretMainPage = () => {
|
|||||||
importedSecrets={importedSecrets}
|
importedSecrets={importedSecrets}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
|
projectSlug={projectSlug}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
@@ -298,6 +314,15 @@ export const SecretMainPage = () => {
|
|||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
/>
|
/>
|
||||||
|
{canReadSecret && (
|
||||||
|
<DynamicSecretListView
|
||||||
|
sortDir={sortDir}
|
||||||
|
environment={environment}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
secretPath={secretPath}
|
||||||
|
dynamicSecrets={dynamicSecrets || []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canReadSecret && (
|
{canReadSecret && (
|
||||||
<SecretListView
|
<SecretListView
|
||||||
secrets={secrets}
|
secrets={secrets}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
faFileImport,
|
faFileImport,
|
||||||
faFilter,
|
faFilter,
|
||||||
|
faFingerprint,
|
||||||
faFolderPlus,
|
faFolderPlus,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faMinusSquare,
|
faMinusSquare,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
useSelectedSecrets
|
useSelectedSecrets
|
||||||
} from "../../SecretMainPage.store";
|
} from "../../SecretMainPage.store";
|
||||||
import { Filter, GroupBy } from "../../SecretMainPage.types";
|
import { Filter, GroupBy } from "../../SecretMainPage.types";
|
||||||
|
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||||
import { FolderForm } from "./FolderForm";
|
import { FolderForm } from "./FolderForm";
|
||||||
|
|
||||||
@@ -60,7 +62,9 @@ type Props = {
|
|||||||
// swtich the secrets type as it gets decrypted after api call
|
// swtich the secrets type as it gets decrypted after api call
|
||||||
importedSecrets?: Array<Omit<TImportedSecrets, "secrets"> & { secrets: DecryptedSecret[] }>;
|
importedSecrets?: Array<Omit<TImportedSecrets, "secrets"> & { secrets: DecryptedSecret[] }>;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
// @depreciated will be moving all these details to zustand
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
projectSlug: string;
|
||||||
secretPath?: string;
|
secretPath?: string;
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
tags?: WsTag[];
|
tags?: WsTag[];
|
||||||
@@ -79,6 +83,7 @@ export const ActionBar = ({
|
|||||||
importedSecrets = [],
|
importedSecrets = [],
|
||||||
environment,
|
environment,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
projectSlug,
|
||||||
secretPath = "/",
|
secretPath = "/",
|
||||||
filter,
|
filter,
|
||||||
tags = [],
|
tags = [],
|
||||||
@@ -93,6 +98,7 @@ export const ActionBar = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
"addFolder",
|
"addFolder",
|
||||||
|
"addDynamicSecret",
|
||||||
"addSecretImport",
|
"addSecretImport",
|
||||||
"bulkDeleteSecrets",
|
"bulkDeleteSecrets",
|
||||||
"misc",
|
"misc",
|
||||||
@@ -311,7 +317,6 @@ export const ActionBar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={popUp.misc.isOpen}
|
open={popUp.misc.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||||
@@ -333,14 +338,14 @@ export const ActionBar = ({
|
|||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faFolderPlus} className="pr-2" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handlePopUpOpen("addFolder");
|
handlePopUpOpen("addFolder");
|
||||||
handlePopUpClose("misc");
|
handlePopUpClose("misc");
|
||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
className="h-10"
|
className="h-10 text-left"
|
||||||
isFullWidth
|
isFullWidth
|
||||||
>
|
>
|
||||||
Add Folder
|
Add Folder
|
||||||
@@ -353,13 +358,37 @@ export const ActionBar = ({
|
|||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
|
leftIcon={<FontAwesomeIcon icon={faFingerprint} className="pr-2" />}
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription && subscription.dynamicSecret) {
|
||||||
|
handlePopUpOpen("addDynamicSecret");
|
||||||
|
handlePopUpClose("misc");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
variant="outline_bg"
|
||||||
|
className="h-10 text-left"
|
||||||
|
isFullWidth
|
||||||
|
>
|
||||||
|
Add Dynamic Secret
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Create}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faFileImport} className="pr-2" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handlePopUpOpen("addSecretImport");
|
handlePopUpOpen("addSecretImport");
|
||||||
handlePopUpClose("misc");
|
handlePopUpClose("misc");
|
||||||
}}
|
}}
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
className="h-10"
|
className="h-10 text-left"
|
||||||
isFullWidth
|
isFullWidth
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
@@ -418,6 +447,13 @@ export const ActionBar = ({
|
|||||||
onClose={() => handlePopUpClose("addSecretImport")}
|
onClose={() => handlePopUpClose("addSecretImport")}
|
||||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
||||||
/>
|
/>
|
||||||
|
<CreateDynamicSecretForm
|
||||||
|
isOpen={popUp.addDynamicSecret.isOpen}
|
||||||
|
onToggle={(isOpen) => handlePopUpToggle("addDynamicSecret", isOpen)}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
environment={environment}
|
||||||
|
secretPath={secretPath}
|
||||||
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp.addFolder.isOpen}
|
isOpen={popUp.addFolder.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||||
@@ -439,8 +475,8 @@ export const ActionBar = ({
|
|||||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
text={
|
text={
|
||||||
subscription.slug === null
|
subscription.slug === null
|
||||||
? "You can perform point-in-time recovery under an Enterprise license"
|
? "You can perform this action under an Enterprise license"
|
||||||
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
|
: "You can perform this action if you switch to Infisical's Team plan"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle: (isOpen: boolean) => void;
|
||||||
|
projectSlug:string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum WizardSteps {
|
||||||
|
SelectProvider = "select-provider",
|
||||||
|
ProviderInputs = "provider-inputs"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateDynamicSecretForm = ({
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
projectSlug,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
}: Props) => {
|
||||||
|
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectProvider);
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<DynamicSecretProviders | null>(null);
|
||||||
|
|
||||||
|
const handleFormReset = (state: boolean = false) => {
|
||||||
|
onToggle(state);
|
||||||
|
setWizardStep(WizardSteps.SelectProvider);
|
||||||
|
setSelectedProvider(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
|
||||||
|
<ModalContent
|
||||||
|
title="Dynamic secret setup"
|
||||||
|
subTitle="Configure dynamic secret parameters"
|
||||||
|
className="my-4 max-h-screen max-w-3xl overflow-scroll"
|
||||||
|
>
|
||||||
|
<AnimatePresence exitBeforeEnter>
|
||||||
|
{wizardStep === WizardSteps.SelectProvider && (
|
||||||
|
<motion.div
|
||||||
|
key="select-type-step"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<div className="mb-4 text-mineshaft-300">Select a service to connect to:</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div
|
||||||
|
className="flex h-32 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:bg-primary/10 hover:border-primary/70 hover:text-white"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvider(DynamicSecretProviders.SqlDatabase);
|
||||||
|
setWizardStep(WizardSteps.ProviderInputs);
|
||||||
|
}}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter") {
|
||||||
|
setSelectedProvider(DynamicSecretProviders.SqlDatabase);
|
||||||
|
setWizardStep(WizardSteps.ProviderInputs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faDatabase} size="lg" />
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
SQL
|
||||||
|
<br />
|
||||||
|
Database
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{wizardStep === WizardSteps.ProviderInputs &&
|
||||||
|
selectedProvider === DynamicSecretProviders.SqlDatabase && (
|
||||||
|
<motion.div
|
||||||
|
key="select-input-step"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<SqlDatabaseInputForm
|
||||||
|
onCompleted={handleFormReset}
|
||||||
|
onCancel={handleFormReset}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
SecretInput,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
TextArea
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||||
|
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
provider: z.object({
|
||||||
|
client: z.nativeEnum(SqlProviders),
|
||||||
|
host: z.string().toLowerCase().min(1),
|
||||||
|
port: z.number(),
|
||||||
|
database: z.string().min(1),
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
creationStatement: z.string().min(1),
|
||||||
|
revocationStatement: z.string().min(1),
|
||||||
|
renewStatement: z.string().min(1),
|
||||||
|
ca: z.string().optional()
|
||||||
|
}),
|
||||||
|
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||||
|
});
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onCompleted: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
secretPath: string;
|
||||||
|
projectSlug: string;
|
||||||
|
environment: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SqlDatabaseInputForm = ({
|
||||||
|
onCompleted,
|
||||||
|
onCancel,
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
projectSlug
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
provider: {
|
||||||
|
creationStatement:
|
||||||
|
"CREATE USER \"{{username}}\" WITH SUPERUSER ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';\nGRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"{{username}}\";",
|
||||||
|
renewStatement: "ALTER ROLE \"{{username}}\" VALID UNTIL '{{expiration}}';",
|
||||||
|
revocationStatement:
|
||||||
|
'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{username}}";\nDROP OWNED BY "{{username}}";\nDROP ROLE "{{username}}";'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const createDynamicSecret = useCreateDynamicSecret();
|
||||||
|
|
||||||
|
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
|
||||||
|
// wait till previous request is finished
|
||||||
|
if (createDynamicSecret.isLoading) return;
|
||||||
|
try {
|
||||||
|
await createDynamicSecret.mutateAsync({
|
||||||
|
provider: { type: DynamicSecretProviders.SqlDatabase, inputs: provider },
|
||||||
|
maxTTL,
|
||||||
|
name,
|
||||||
|
path: secretPath,
|
||||||
|
defaultTTL,
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment
|
||||||
|
});
|
||||||
|
onCompleted();
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to create dynamic secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(handleCreateDynamicSecret)}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Secret Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="dynamic-postgres" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="defaultTTL"
|
||||||
|
defaultValue="1h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Default TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxTTL"
|
||||||
|
defaultValue="24h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Max TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="pl-1 pb-0.5 text-sm text-mineshaft-400">Service</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.client"
|
||||||
|
defaultValue={SqlProviders.Postgres}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val)}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.host"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Host"
|
||||||
|
className="flex-grow"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.port"
|
||||||
|
defaultValue={5432}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Port"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} type="number" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.username"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="User"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.password"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Password"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} type="password" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.database"
|
||||||
|
defaultValue="default"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Database Name"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.ca"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isOptional
|
||||||
|
label="CA(SSL)"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
{...field}
|
||||||
|
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
|
||||||
|
<AccordionItem value="advance-statements">
|
||||||
|
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.creationStatement"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Creation Statement"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="username, password and expiration are dynamically provisioned"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
reSize="none"
|
||||||
|
rows={3}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.revocationStatement"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Revocation Statement"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="username is dynamically provisioned"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
reSize="none"
|
||||||
|
rows={3}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="provider.renewStatement"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Renew Statement"
|
||||||
|
helperText="username and expiration are dynamically provisioned"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
reSize="none"
|
||||||
|
rows={3}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { Button, FormControl, IconButton, Input, SecretInput, Tooltip } from "@app/components/v2";
|
||||||
|
import { useTimedReset } from "@app/hooks";
|
||||||
|
import { useCreateDynamicSecretLease } from "@app/hooks/api";
|
||||||
|
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
const OutputDisplay = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
helperText
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
helperText?: string;
|
||||||
|
}) => {
|
||||||
|
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
|
||||||
|
initialState: "Copy to clipboard"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl label={label} className="flex-grow" helperText={helperText}>
|
||||||
|
<SecretInput
|
||||||
|
isReadOnly
|
||||||
|
value={value}
|
||||||
|
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Tooltip content={copyText}>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="Copy to clipboard"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className="absolute right-2 top-7"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(value as string);
|
||||||
|
setCopyText("Copied");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
|
||||||
|
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
|
||||||
|
if (provider === DynamicSecretProviders.SqlDatabase) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<OutputDisplay label="Database User" value={DB_USERNAME} />
|
||||||
|
<OutputDisplay
|
||||||
|
label="Database Password"
|
||||||
|
value={DB_PASSWORD}
|
||||||
|
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number")
|
||||||
|
});
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
dynamicSecretName: string;
|
||||||
|
provider: DynamicSecretProviders;
|
||||||
|
projectSlug: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateDynamicSecretLease = ({
|
||||||
|
onClose,
|
||||||
|
projectSlug,
|
||||||
|
dynamicSecretName,
|
||||||
|
provider,
|
||||||
|
secretPath,
|
||||||
|
environment
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
ttl: "1h"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
|
||||||
|
const createDynamicSecretLease = useCreateDynamicSecretLease();
|
||||||
|
|
||||||
|
const handleDynamicSecretLeaseCreate = async ({ ttl }: TForm) => {
|
||||||
|
if (createDynamicSecretLease.isLoading) return;
|
||||||
|
try {
|
||||||
|
await createDynamicSecretLease.mutateAsync({
|
||||||
|
environmentSlug: environment,
|
||||||
|
projectSlug,
|
||||||
|
path: secretPath,
|
||||||
|
ttl,
|
||||||
|
dynamicSecretName
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully created dynamic secret"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to deleted dynamic secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOutputMode = Boolean(createDynamicSecretLease?.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isOutputMode && (
|
||||||
|
<motion.div
|
||||||
|
key="lease-input"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(handleDynamicSecretLeaseCreate)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ttl"
|
||||||
|
defaultValue="1h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Default TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{isOutputMode && (
|
||||||
|
<motion.div
|
||||||
|
key="lease-output"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
|
>
|
||||||
|
{renderOutputForm(provider, createDynamicSecretLease.data?.data)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import {
|
||||||
|
faClose,
|
||||||
|
faFileContract,
|
||||||
|
faRepeat,
|
||||||
|
faTrash,
|
||||||
|
faWarning
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { format, formatDistance } from "date-fns";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
THead,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
|
||||||
|
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
|
||||||
|
|
||||||
|
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dynamicSecretName: string;
|
||||||
|
projectSlug: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
onClickNewLease: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DynamicSecretLease = ({
|
||||||
|
projectSlug,
|
||||||
|
dynamicSecretName,
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
onClickNewLease,
|
||||||
|
onClose
|
||||||
|
}: Props) => {
|
||||||
|
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
|
"deleteSecret",
|
||||||
|
"renewSecret"
|
||||||
|
] as const);
|
||||||
|
const { data: leases, isLoading: isLeaseLoading } = useGetDynamicSecretLeases({
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment,
|
||||||
|
path: secretPath,
|
||||||
|
dynamicSecretName
|
||||||
|
});
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
|
||||||
|
const deleteDynamicSecretLease = useRevokeDynamicSecretLease();
|
||||||
|
|
||||||
|
const handleDynamicSecretDeleteLease = async () => {
|
||||||
|
try {
|
||||||
|
const { leaseId, isForced } = popUp.deleteSecret.data as {
|
||||||
|
leaseId: string;
|
||||||
|
isForced?: boolean;
|
||||||
|
};
|
||||||
|
await deleteDynamicSecretLease.mutateAsync({
|
||||||
|
environmentSlug: environment,
|
||||||
|
projectSlug,
|
||||||
|
path: secretPath,
|
||||||
|
dynamicSecretName,
|
||||||
|
leaseId,
|
||||||
|
isForced
|
||||||
|
});
|
||||||
|
handlePopUpClose("deleteSecret");
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted lease"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete lease"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TableContainer>
|
||||||
|
<Table className="bg-mineshaft-600">
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Td>Lease ID</Td>
|
||||||
|
<Td>Expire At</Td>
|
||||||
|
<Td />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{!isLeaseLoading && leases?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3}>
|
||||||
|
<EmptyState title="No leases found" icon={faFileContract}>
|
||||||
|
<Button
|
||||||
|
onClick={onClickNewLease}
|
||||||
|
className="mt-4"
|
||||||
|
colorSchema="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
New Lease
|
||||||
|
</Button>
|
||||||
|
</EmptyState>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{(leases || []).map(({ id, expireAt, status, statusDetails }) => (
|
||||||
|
<Tr key={id}>
|
||||||
|
<Td>
|
||||||
|
{id}
|
||||||
|
{Boolean(status) && (
|
||||||
|
<Tooltip content={statusDetails || status || ""}>
|
||||||
|
<FontAwesomeIcon className="ml-2 text-yellow-600" icon={faWarning} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Tooltip content={format(new Date(expireAt), "yyyy-MM-dd, hh:mm aaa")}>
|
||||||
|
<span className="capitalize">
|
||||||
|
{formatDistance(new Date(expireAt), new Date())}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Renew"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="edit-folder"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className="p-0"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRepeat} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Delete"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="delete-folder"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className="p-0"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={() => handlePopUpOpen("deleteSecret", { leaseId: id })}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
{status === DynamicSecretLeaseStatus.FailedDeletion && (
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="delete-folder"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className="p-0 text-red-600"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("deleteSecret", { leaseId: id, isForced: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
{!isLeaseLoading && Boolean(leases?.length) && (
|
||||||
|
<div className="mt-6 flex items-center space-x-4">
|
||||||
|
<Button onClick={onClickNewLease} size="xs">
|
||||||
|
New Lease
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="plain" colorSchema="secondary" size="xs">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.renewSecret.isOpen}
|
||||||
|
onOpenChange={(state) => handlePopUpToggle("renewSecret", state)}
|
||||||
|
>
|
||||||
|
<ModalContent title="Renew Lease">
|
||||||
|
<RenewDynamicSecretLease
|
||||||
|
onClose={() => handlePopUpClose("renewSecret")}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
leaseId={(popUp.renewSecret?.data as { leaseId: string })?.leaseId}
|
||||||
|
dynamicSecretName={dynamicSecretName}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteSecret.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title="Do you want to delete this lease?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
|
||||||
|
onDeleteApproved={handleDynamicSecretDeleteLease}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import {
|
||||||
|
faClose,
|
||||||
|
faFingerprint,
|
||||||
|
faPencilSquare,
|
||||||
|
faWarning
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Tag,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useDeleteDynamicSecret } from "@app/hooks/api";
|
||||||
|
import {
|
||||||
|
DynamicSecretProviders,
|
||||||
|
DynamicSecretStatus,
|
||||||
|
TDynamicSecret
|
||||||
|
} from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
import { SortDir } from "../../SecretMainPage.types";
|
||||||
|
import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease";
|
||||||
|
import { DynamicSecretLease } from "./DynamicSecretLease";
|
||||||
|
import { EditDynamicSecretForm } from "./EditDynamicSecretForm";
|
||||||
|
|
||||||
|
const formatProviderName = (type: DynamicSecretProviders) => {
|
||||||
|
if (type === DynamicSecretProviders.SqlDatabase) return "SQL Database";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dynamicSecrets: TDynamicSecret[];
|
||||||
|
environment: string;
|
||||||
|
projectSlug: string;
|
||||||
|
secretPath?: string;
|
||||||
|
sortDir: SortDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DynamicSecretListView = ({
|
||||||
|
dynamicSecrets = [],
|
||||||
|
environment,
|
||||||
|
projectSlug,
|
||||||
|
secretPath = "/",
|
||||||
|
sortDir = SortDir.ASC
|
||||||
|
}: Props) => {
|
||||||
|
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"dynamicSecretLeases",
|
||||||
|
"createDynamicSecretLease",
|
||||||
|
"updateDynamicSecret",
|
||||||
|
"deleteDynamicSecret"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const deleteDynamicSecret = useDeleteDynamicSecret();
|
||||||
|
|
||||||
|
const handleDynamicSecretDelete = async () => {
|
||||||
|
try {
|
||||||
|
const { name, isForced } = popUp.deleteDynamicSecret.data as TDynamicSecret & {
|
||||||
|
isForced?: boolean;
|
||||||
|
};
|
||||||
|
await deleteDynamicSecret.mutateAsync({
|
||||||
|
environmentSlug: environment,
|
||||||
|
projectSlug,
|
||||||
|
path: secretPath,
|
||||||
|
name,
|
||||||
|
isForced
|
||||||
|
});
|
||||||
|
handlePopUpClose("deleteDynamicSecret");
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted dynamic secret"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete dynamic secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{dynamicSecrets
|
||||||
|
.sort((a, b) =>
|
||||||
|
sortDir === SortDir.ASC
|
||||||
|
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||||
|
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
||||||
|
)
|
||||||
|
.map((secret) => {
|
||||||
|
const isRevocking = secret.status === DynamicSecretStatus.Deleting;
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
key={secret.id}
|
||||||
|
isOpen={
|
||||||
|
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
|
||||||
|
}
|
||||||
|
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter" && !isRevocking)
|
||||||
|
handlePopUpOpen("dynamicSecretLeases", secret.id);
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isRevocking) {
|
||||||
|
handlePopUpOpen("dynamicSecretLeases", secret.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
|
||||||
|
<FontAwesomeIcon icon={faFingerprint} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex flex-grow items-center px-4 py-3"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{secret.name}
|
||||||
|
<Tag className="ml-4 py-0 px-2 text-xs normal-case">
|
||||||
|
{formatProviderName(secret.type)}
|
||||||
|
</Tag>
|
||||||
|
{Boolean(secret.status) && (
|
||||||
|
<Tooltip content={secret?.statusDetails || secret.status || ""}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={
|
||||||
|
secret.status === DynamicSecretStatus.Deleting
|
||||||
|
? "text-yellow-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
icon={faWarning}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 px-4 py-2">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
|
||||||
|
isDisabled={isRevocking}
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
handlePopUpOpen("createDynamicSecretLease", secret);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
{secret.status === DynamicSecretStatus.FailedDeletion && (
|
||||||
|
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
className="m-0 py-0.5 px-2"
|
||||||
|
colorSchema="danger"
|
||||||
|
isDisabled={isRevocking}
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteDynamicSecret", {
|
||||||
|
...secret,
|
||||||
|
isForced: true
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Force Delete
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Edit"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="edit-dynamic-secret"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
handlePopUpOpen("updateDynamicSecret", secret);
|
||||||
|
}}
|
||||||
|
isDisabled={!isAllowed || isRevocking}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Delete"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="delete-dynamic-secret"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteDynamicSecret", secret);
|
||||||
|
}}
|
||||||
|
isDisabled={!isAllowed || isRevocking}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModalContent
|
||||||
|
title="Dynamic secret leases"
|
||||||
|
subTitle="Revoke or renew your secret leases"
|
||||||
|
className="max-w-3xl"
|
||||||
|
>
|
||||||
|
<DynamicSecretLease
|
||||||
|
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
|
||||||
|
onClose={() => handlePopUpClose("dynamicSecretLeases")}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
key={secret.id}
|
||||||
|
dynamicSecretName={secret.name}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.createDynamicSecretLease.isOpen}
|
||||||
|
onOpenChange={(state) => handlePopUpToggle("createDynamicSecretLease", state)}
|
||||||
|
>
|
||||||
|
<ModalContent title="Provision lease">
|
||||||
|
<CreateDynamicSecretLease
|
||||||
|
provider={
|
||||||
|
(popUp.createDynamicSecretLease?.data as { type: DynamicSecretProviders })?.type
|
||||||
|
}
|
||||||
|
onClose={() => handlePopUpClose("createDynamicSecretLease")}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
dynamicSecretName={(popUp.createDynamicSecretLease?.data as { name: string })?.name}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.updateDynamicSecret.isOpen}
|
||||||
|
onOpenChange={(state) => handlePopUpToggle("updateDynamicSecret", state)}
|
||||||
|
>
|
||||||
|
<ModalContent title="Edit dynamic secret" className="max-w-3xl">
|
||||||
|
<EditDynamicSecretForm
|
||||||
|
onClose={() => handlePopUpClose("updateDynamicSecret")}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
dynamicSecretName={(popUp.updateDynamicSecret?.data as TDynamicSecret)?.name}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteDynamicSecret.isOpen}
|
||||||
|
deleteKey={(popUp.deleteDynamicSecret?.data as TDynamicSecret)?.name}
|
||||||
|
title={
|
||||||
|
(popUp.deleteDynamicSecret?.data as { isForced?: boolean })?.isForced
|
||||||
|
? "Do you want to force delete this dynamic secret?"
|
||||||
|
: "Do you want to delete this dynamic secret?"
|
||||||
|
}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteDynamicSecret", isOpen)}
|
||||||
|
onDeleteApproved={handleDynamicSecretDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { Spinner } from "@app/components/v2";
|
||||||
|
import { useGetDynamicSecretDetails } from "@app/hooks/api";
|
||||||
|
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
dynamicSecretName: string;
|
||||||
|
projectSlug: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDynamicSecretForm = ({
|
||||||
|
dynamicSecretName,
|
||||||
|
environment,
|
||||||
|
projectSlug,
|
||||||
|
onClose,
|
||||||
|
secretPath
|
||||||
|
}: Props) => {
|
||||||
|
const { data: dynamicSecretDetails, isLoading: isDynamicSecretLoading } =
|
||||||
|
useGetDynamicSecretDetails({
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment,
|
||||||
|
name: dynamicSecretName,
|
||||||
|
path: secretPath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDynamicSecretLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center p-8">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence exitBeforeEnter>
|
||||||
|
{dynamicSecretDetails?.type === DynamicSecretProviders.SqlDatabase && (
|
||||||
|
<motion.div
|
||||||
|
key="sqldatabase-provider-edit"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<EditDynamicSecretSqlProviderForm
|
||||||
|
onClose={onClose}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
secretPath={secretPath}
|
||||||
|
dynamicSecret={dynamicSecretDetails}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
SecretInput,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
TextArea
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||||
|
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
inputs: z
|
||||||
|
.object({
|
||||||
|
client: z.nativeEnum(SqlProviders),
|
||||||
|
host: z.string().toLowerCase().min(1),
|
||||||
|
port: z.number(),
|
||||||
|
database: z.string().min(1),
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
creationStatement: z.string().min(1),
|
||||||
|
revocationStatement: z.string().min(1),
|
||||||
|
renewStatement: z.string().min(1),
|
||||||
|
ca: z.string().optional()
|
||||||
|
})
|
||||||
|
.partial(),
|
||||||
|
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
newName: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDynamicSecretSqlProviderForm = ({
|
||||||
|
onClose,
|
||||||
|
dynamicSecret,
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
projectSlug
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
values: {
|
||||||
|
defaultTTL: dynamicSecret.defaultTTL,
|
||||||
|
maxTTL: dynamicSecret.maxTTL,
|
||||||
|
newName: dynamicSecret.name,
|
||||||
|
inputs: {
|
||||||
|
...(dynamicSecret.inputs as TForm["inputs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||||
|
|
||||||
|
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
|
||||||
|
// wait till previous request is finished
|
||||||
|
if (updateDynamicSecret.isLoading) return;
|
||||||
|
try {
|
||||||
|
await updateDynamicSecret.mutateAsync({
|
||||||
|
name: dynamicSecret.name,
|
||||||
|
path: secretPath,
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment,
|
||||||
|
data: {
|
||||||
|
maxTTL: maxTTL || undefined,
|
||||||
|
defaultTTL,
|
||||||
|
inputs,
|
||||||
|
newName: newName === dynamicSecret.name ? undefined : newName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully update dynamic secret"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to update dynamic secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="newName"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Secret Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="DYN-1" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="defaultTTL"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Default TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxTTL"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Max TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.client"
|
||||||
|
defaultValue={SqlProviders.Postgres}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val)}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.host"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Host"
|
||||||
|
className="flex-grow"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.port"
|
||||||
|
defaultValue={5432}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Port"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} type="number" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.username"
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="User"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.password"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Password"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} type="password" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.database"
|
||||||
|
defaultValue="default"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Database Name"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.ca"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isOptional
|
||||||
|
label="CA(SSL)"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
{...field}
|
||||||
|
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Accordion type="multiple" className="w-full bg-mineshaft-700">
|
||||||
|
<AccordionItem value="modify-sql-statement">
|
||||||
|
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.creationStatement"
|
||||||
|
defaultValue={
|
||||||
|
"CREATE USER \"{{username}}\" WITH SUPERUSER ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';\nGRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"{{username}}\";"
|
||||||
|
}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Creation Statement"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="username, password and expiration are dynamically provisioned"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
reSize="none"
|
||||||
|
rows={3}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.revocationStatement"
|
||||||
|
defaultValue={
|
||||||
|
'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{username}}";\nDROP OWNED BY "{{username}}"; DROP ROLE "{{username}}";'
|
||||||
|
}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Revocation Statement"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="username is dynamically provisioned"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
reSize="none"
|
||||||
|
rows={3}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="inputs.renewStatement"
|
||||||
|
defaultValue={"ALTER ROLE \"{{username}}\" VALID UNTIL '{{expiration}}';"}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Renew Statement"
|
||||||
|
helperText="username and expiration are dynamically provisioned"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
reSize="none"
|
||||||
|
rows={3}
|
||||||
|
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { EditDynamicSecretForm } from "./EditDynamicSecretForm";
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { Button, FormControl, Input } from "@app/components/v2";
|
||||||
|
import { useRenewDynamicSecretLease } from "@app/hooks/api";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
ttl: z.string().superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
})
|
||||||
|
});
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
leaseId: string;
|
||||||
|
dynamicSecretName: string;
|
||||||
|
projectSlug: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RenewDynamicSecretLease = ({
|
||||||
|
onClose,
|
||||||
|
projectSlug,
|
||||||
|
dynamicSecretName,
|
||||||
|
leaseId,
|
||||||
|
secretPath,
|
||||||
|
environment
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
ttl: "1h"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
|
||||||
|
const renewDynamicSecretLease = useRenewDynamicSecretLease();
|
||||||
|
|
||||||
|
const handleDynamicSecretLeaseCreate = async ({ ttl }: TForm) => {
|
||||||
|
if (renewDynamicSecretLease.isLoading) return;
|
||||||
|
try {
|
||||||
|
await renewDynamicSecretLease.mutateAsync({
|
||||||
|
environmentSlug: environment,
|
||||||
|
projectSlug,
|
||||||
|
path: secretPath,
|
||||||
|
ttl,
|
||||||
|
dynamicSecretName,
|
||||||
|
leaseId
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully renewed lease"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to renew lease"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleDynamicSecretLeaseCreate)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ttl"
|
||||||
|
defaultValue="1h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="The existing expiration time will be extended by the TTL"
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Renew
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DynamicSecretListView } from "./DynamicSecretListView";
|
||||||
Reference in New Issue
Block a user