Merge pull request #3391 from Infisical/feat/add-metadata-based-permissions-for-dynamic-secret

feat: add metadata based permissions for dynamic secret
This commit is contained in:
Sheen
2025-04-16 04:28:52 +08:00
committed by GitHub
33 changed files with 992 additions and 227 deletions
@@ -0,0 +1,20 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId"))) {
await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => {
tb.uuid("dynamicSecretId");
tb.foreign("dynamicSecretId").references("id").inTable(TableName.DynamicSecret).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.ResourceMetadata, "dynamicSecretId")) {
await knex.schema.alterTable(TableName.ResourceMetadata, (tb) => {
tb.dropColumn("dynamicSecretId");
});
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ export const ResourceMetadataSchema = z.object({
identityId: z.string().uuid().nullable().optional(), identityId: z.string().uuid().nullable().optional(),
secretId: z.string().uuid().nullable().optional(), secretId: z.string().uuid().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
dynamicSecretId: z.string().uuid().nullable().optional()
}); });
export type TResourceMetadata = z.infer<typeof ResourceMetadataSchema>; export type TResourceMetadata = z.infer<typeof ResourceMetadataSchema>;
@@ -11,6 +11,7 @@ import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -48,7 +49,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
.nullable(), .nullable(),
path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash),
environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1),
name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name) name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name),
metadata: ResourceMetadataSchema.optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -143,7 +145,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}) })
.nullable(), .nullable(),
newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional() newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(),
metadata: ResourceMetadataSchema.optional()
}) })
}), }),
response: { response: {
@@ -238,6 +241,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
name: req.params.name, name: req.params.name,
...req.query ...req.query
}); });
return { dynamicSecret: dynamicSecretCfg }; return { dynamicSecret: dynamicSecretCfg };
} }
}); });
@@ -78,10 +78,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan?.dynamicSecret) { if (!plan?.dynamicSecret) {
@@ -102,6 +98,15 @@ export const dynamicSecretLeaseServiceFactory = ({
message: `Dynamic secret with name '${name}' in folder with path '${path}' not found` message: `Dynamic secret with name '${name}' in folder with path '${path}' not found`
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id); const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id);
if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT) if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT)
throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` }); throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` });
@@ -159,10 +164,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
@@ -187,7 +188,25 @@ export const dynamicSecretLeaseServiceFactory = ({
throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
} }
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; const dynamicSecretCfg = await dynamicSecretDAL.findOne({
id: dynamicSecretLease.dynamicSecretId,
folderId: folder.id
});
if (!dynamicSecretCfg)
throw new NotFoundError({
message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const decryptedStoredInput = JSON.parse( const decryptedStoredInput = JSON.parse(
secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString() secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString()
@@ -239,10 +258,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
@@ -259,7 +274,25 @@ export const dynamicSecretLeaseServiceFactory = ({
if (!dynamicSecretLease || dynamicSecretLease.dynamicSecret.folderId !== folder.id) if (!dynamicSecretLease || dynamicSecretLease.dynamicSecret.folderId !== folder.id)
throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; const dynamicSecretCfg = await dynamicSecretDAL.findOne({
id: dynamicSecretLease.dynamicSecretId,
folderId: folder.id
});
if (!dynamicSecretCfg)
throw new NotFoundError({
message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const decryptedStoredInput = JSON.parse( const decryptedStoredInput = JSON.parse(
secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString() secretManagerDecryptor({ cipherTextBlob: Buffer.from(dynamicSecretCfg.encryptedInput) }).toString()
@@ -309,10 +342,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) if (!folder)
@@ -326,6 +355,15 @@ export const dynamicSecretLeaseServiceFactory = ({
message: `Dynamic secret with name '${name}' in folder with path '${path}' not found` message: `Dynamic secret with name '${name}' in folder with path '${path}' not found`
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
return dynamicSecretLeases; return dynamicSecretLeases;
}; };
@@ -352,10 +390,6 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) throw new NotFoundError({ message: `Folder with path '${path}' not found` }); if (!folder) throw new NotFoundError({ message: `Folder with path '${path}' not found` });
@@ -364,6 +398,25 @@ export const dynamicSecretLeaseServiceFactory = ({
if (!dynamicSecretLease) if (!dynamicSecretLease)
throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
const dynamicSecretCfg = await dynamicSecretDAL.findOne({
id: dynamicSecretLease.dynamicSecretId,
folderId: folder.id
});
if (!dynamicSecretCfg)
throw new NotFoundError({
message: `Dynamic secret with ID '${dynamicSecretLease.dynamicSecretId}' not found`
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
return dynamicSecretLease; return dynamicSecretLease;
}; };
@@ -1,9 +1,17 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName, TDynamicSecrets } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import {
buildFindFilter,
ormify,
prependTableNameToFindFilter,
selectAllTableCols,
sqlNestRelationships,
TFindFilter,
TFindOpt
} from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
@@ -12,6 +20,86 @@ export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory
export const dynamicSecretDALFactory = (db: TDbClient) => { export const dynamicSecretDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.DynamicSecret); const orm = ormify(db, TableName.DynamicSecret);
const findOne = async (filter: TFindFilter<TDynamicSecrets>, tx?: Knex) => {
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.leftJoin(
TableName.ResourceMetadata,
`${TableName.ResourceMetadata}.dynamicSecretId`,
`${TableName.DynamicSecret}.id`
)
.select(selectAllTableCols(TableName.DynamicSecret))
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
.where(prependTableNameToFindFilter(TableName.DynamicSecret, filter));
const docs = sqlNestRelationships({
data: await query,
key: "id",
parentMapper: (el) => el,
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return docs[0];
};
const findWithMetadata = async (
filter: TFindFilter<TDynamicSecrets>,
{ offset, limit, sort, tx }: TFindOpt<TDynamicSecrets> = {}
) => {
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.leftJoin(
TableName.ResourceMetadata,
`${TableName.ResourceMetadata}.dynamicSecretId`,
`${TableName.DynamicSecret}.id`
)
.select(selectAllTableCols(TableName.DynamicSecret))
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter));
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const docs = sqlNestRelationships({
data: await query,
key: "id",
parentMapper: (el) => el,
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return docs;
};
// find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination) // find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination)
const listDynamicSecretsByFolderIds = async ( const listDynamicSecretsByFolderIds = async (
{ {
@@ -39,18 +127,27 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`); void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`);
} }
}) })
.leftJoin(
TableName.ResourceMetadata,
`${TableName.ResourceMetadata}.dynamicSecretId`,
`${TableName.DynamicSecret}.id`
)
.leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`) .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`)
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select( .select(
selectAllTableCols(TableName.DynamicSecret), selectAllTableCols(TableName.DynamicSecret),
db.ref("slug").withSchema(TableName.Environment).as("environment"), db.ref("slug").withSchema(TableName.Environment).as("environment"),
db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`) db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`),
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
) )
.orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection); .orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection);
let queryWithLimit;
if (limit) { if (limit) {
const rankOffset = offset + 1; const rankOffset = offset + 1;
return await (tx || db) queryWithLimit = (tx || db.replicaNode())
.with("w", query) .with("w", query)
.select("*") .select("*")
.from<Awaited<typeof query>[number]>("w") .from<Awaited<typeof query>[number]>("w")
@@ -58,7 +155,22 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
.andWhere("w.rank", "<", rankOffset + limit); .andWhere("w.rank", "<", rankOffset + limit);
} }
const dynamicSecrets = await query; const dynamicSecrets = sqlNestRelationships({
data: await (queryWithLimit || query),
key: "id",
parentMapper: (el) => el,
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return dynamicSecrets; return dynamicSecrets;
} catch (error) { } catch (error) {
@@ -66,5 +178,5 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
} }
}; };
return { ...orm, listDynamicSecretsByFolderIds }; return { ...orm, listDynamicSecretsByFolderIds, findOne, findWithMetadata };
}; };
@@ -12,6 +12,7 @@ import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal";
@@ -46,6 +47,7 @@ type TDynamicSecretServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
projectGatewayDAL: Pick<TProjectGatewayDALFactory, "findOne">; projectGatewayDAL: Pick<TProjectGatewayDALFactory, "findOne">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
}; };
export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>; export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>;
@@ -60,7 +62,8 @@ export const dynamicSecretServiceFactory = ({
dynamicSecretQueueService, dynamicSecretQueueService,
projectDAL, projectDAL,
kmsService, kmsService,
projectGatewayDAL projectGatewayDAL,
resourceMetadataDAL
}: TDynamicSecretServiceFactoryDep) => { }: TDynamicSecretServiceFactoryDep) => {
const create = async ({ const create = async ({
path, path,
@@ -73,7 +76,8 @@ export const dynamicSecretServiceFactory = ({
projectSlug, projectSlug,
actorOrgId, actorOrgId,
defaultTTL, defaultTTL,
actorAuthMethod actorAuthMethod,
metadata
}: TCreateDynamicSecretDTO) => { }: TCreateDynamicSecretDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -87,9 +91,10 @@ export const dynamicSecretServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.CreateRootCredential, ProjectPermissionDynamicSecretActions.CreateRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path, metadata })
); );
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -131,16 +136,36 @@ export const dynamicSecretServiceFactory = ({
projectId projectId
}); });
const dynamicSecretCfg = await dynamicSecretDAL.create({ const dynamicSecretCfg = await dynamicSecretDAL.transaction(async (tx) => {
type: provider.type, const cfg = await dynamicSecretDAL.create(
version: 1, {
encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(inputs)) }).cipherTextBlob, type: provider.type,
maxTTL, version: 1,
defaultTTL, encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(inputs)) }).cipherTextBlob,
folderId: folder.id, maxTTL,
name, defaultTTL,
projectGatewayId: selectedGatewayId folderId: folder.id,
name,
projectGatewayId: selectedGatewayId
},
tx
);
if (metadata) {
await resourceMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
key,
value,
dynamicSecretId: cfg.id,
orgId: actorOrgId
})),
tx
);
}
return cfg;
}); });
return dynamicSecretCfg; return dynamicSecretCfg;
}; };
@@ -156,7 +181,8 @@ export const dynamicSecretServiceFactory = ({
actorId, actorId,
newName, newName,
actorOrgId, actorOrgId,
actorAuthMethod actorAuthMethod,
metadata
}: TUpdateDynamicSecretDTO) => { }: TUpdateDynamicSecretDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -171,10 +197,6 @@ export const dynamicSecretServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan?.dynamicSecret) { if (!plan?.dynamicSecret) {
@@ -193,6 +215,27 @@ export const dynamicSecretServiceFactory = ({
message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found` message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found`
}); });
} }
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
if (metadata) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata
})
);
}
if (newName) { if (newName) {
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id }); const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id });
if (existingDynamicSecret) if (existingDynamicSecret)
@@ -231,14 +274,41 @@ export const dynamicSecretServiceFactory = ({
const isConnected = await selectedProvider.validateConnection(newInput); const isConnected = await selectedProvider.validateConnection(newInput);
if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" });
const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { const updatedDynamicCfg = await dynamicSecretDAL.transaction(async (tx) => {
encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(updatedInput)) }).cipherTextBlob, const cfg = await dynamicSecretDAL.updateById(
maxTTL, dynamicSecretCfg.id,
defaultTTL, {
name: newName ?? name, encryptedInput: secretManagerEncryptor({ plainText: Buffer.from(JSON.stringify(updatedInput)) })
status: null, .cipherTextBlob,
statusDetails: null, maxTTL,
projectGatewayId: selectedGatewayId defaultTTL,
name: newName ?? name,
status: null,
projectGatewayId: selectedGatewayId
},
tx
);
if (metadata) {
await resourceMetadataDAL.delete(
{
dynamicSecretId: cfg.id
},
tx
);
await resourceMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
key,
value,
dynamicSecretId: cfg.id,
orgId: actorOrgId
})),
tx
);
}
return cfg;
}); });
return updatedDynamicCfg; return updatedDynamicCfg;
@@ -268,10 +338,6 @@ export const dynamicSecretServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.DeleteRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) if (!folder)
@@ -282,6 +348,15 @@ export const dynamicSecretServiceFactory = ({
throw new NotFoundError({ message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found` }); throw new NotFoundError({ message: `Dynamic secret with name '${name}' in folder '${folder.path}' not found` });
} }
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.DeleteRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id });
// when not forced we check with the external system to first remove the things // 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 // we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system
@@ -329,14 +404,6 @@ export const dynamicSecretServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) if (!folder)
@@ -346,6 +413,25 @@ export const dynamicSecretServiceFactory = ({
if (!dynamicSecretCfg) { if (!dynamicSecretCfg) {
throw new NotFoundError({ message: `Dynamic secret with name '${name} in folder '${path}' not found` }); throw new NotFoundError({ message: `Dynamic secret with name '${name} in folder '${path}' not found` });
} }
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecretCfg.metadata
})
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
projectId projectId
@@ -356,6 +442,7 @@ export const dynamicSecretServiceFactory = ({
) as object; ) as object;
const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders];
const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object;
return { ...dynamicSecretCfg, inputs: providerInputs }; return { ...dynamicSecretCfg, inputs: providerInputs };
}; };
@@ -426,7 +513,7 @@ export const dynamicSecretServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) ProjectPermissionSub.DynamicSecrets
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -473,16 +560,12 @@ export const dynamicSecretServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) if (!folder)
throw new NotFoundError({ message: `Folder with path '${path}' in environment '${environmentSlug}' not found` }); throw new NotFoundError({ message: `Folder with path '${path}' in environment '${environmentSlug}' not found` });
const dynamicSecretCfg = await dynamicSecretDAL.find( const dynamicSecretCfg = await dynamicSecretDAL.findWithMetadata(
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined }, { folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
{ {
limit, limit,
@@ -490,7 +573,17 @@ export const dynamicSecretServiceFactory = ({
sort: orderBy ? [[orderBy, orderDirection]] : undefined sort: orderBy ? [[orderBy, orderDirection]] : undefined
} }
); );
return dynamicSecretCfg;
return dynamicSecretCfg.filter((dynamicSecret) => {
return permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: environmentSlug,
secretPath: path,
metadata: dynamicSecret.metadata
})
);
});
}; };
const listDynamicSecretsByFolderIds = async ( const listDynamicSecretsByFolderIds = async (
@@ -542,24 +635,14 @@ export const dynamicSecretServiceFactory = ({
isInternal, isInternal,
...params ...params
}: TListDynamicSecretsMultiEnvDTO) => { }: TListDynamicSecretsMultiEnvDTO) => {
if (!isInternal) { const { permission } = await permissionService.getProjectPermission({
const { permission } = await permissionService.getProjectPermission({ actor,
actor, actorId,
actorId, projectId,
projectId, actorAuthMethod,
actorAuthMethod, actorOrgId,
actorOrgId, actionProjectType: ActionProjectType.SecretManager
actionProjectType: ActionProjectType.SecretManager });
});
// verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
)
);
}
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path); const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
if (!folders.length) if (!folders.length)
@@ -572,7 +655,16 @@ export const dynamicSecretServiceFactory = ({
...params ...params
}); });
return dynamicSecretCfg; return dynamicSecretCfg.filter((dynamicSecret) => {
return permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: dynamicSecret.environment,
secretPath: path,
metadata: dynamicSecret.metadata
})
);
});
}; };
const fetchAzureEntraIdUsers = async ({ const fetchAzureEntraIdUsers = async ({
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { DynamicSecretProviderSchema } from "./providers/models"; import { DynamicSecretProviderSchema } from "./providers/models";
@@ -20,6 +21,7 @@ export type TCreateDynamicSecretDTO = {
environmentSlug: string; environmentSlug: string;
name: string; name: string;
projectSlug: string; projectSlug: string;
metadata?: ResourceMetadataDTO;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateDynamicSecretDTO = { export type TUpdateDynamicSecretDTO = {
@@ -31,6 +33,7 @@ export type TUpdateDynamicSecretDTO = {
environmentSlug: string; environmentSlug: string;
inputs?: TProvider["inputs"]; inputs?: TProvider["inputs"];
projectSlug: string; projectSlug: string;
metadata?: ResourceMetadataDTO;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteDynamicSecretDTO = { export type TDeleteDynamicSecretDTO = {
@@ -155,6 +155,10 @@ export type SecretFolderSubjectFields = {
export type DynamicSecretSubjectFields = { export type DynamicSecretSubjectFields = {
environment: string; environment: string;
secretPath: string; secretPath: string;
metadata?: {
key: string;
value: string;
}[];
}; };
export type SecretImportSubjectFields = { export type SecretImportSubjectFields = {
@@ -284,6 +288,42 @@ const SecretConditionV1Schema = z
}) })
.partial(); .partial();
const DynamicSecretConditionV2Schema = z
.object({
environment: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
]),
secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA,
metadata: z.object({
[PermissionConditionOperators.$ELEMENTMATCH]: z
.object({
key: z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial(),
value: z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
})
.partial()
})
})
.partial();
const SecretConditionV2Schema = z const SecretConditionV2Schema = z
.object({ .object({
environment: z.union([ environment: z.union([
@@ -581,7 +621,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionDynamicSecretActions).describe( action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionDynamicSecretActions).describe(
"Describe what action an entity can take." "Describe what action an entity can take."
), ),
conditions: SecretConditionV1Schema.describe( conditions: DynamicSecretConditionV2Schema.describe(
"When specified, only matching conditions will be allowed to access given resource." "When specified, only matching conditions will be allowed to access given resource."
).optional() ).optional()
}), }),
+2 -1
View File
@@ -24,5 +24,6 @@ export enum PermissionConditionOperators {
$IN = "$in", $IN = "$in",
$EQ = "$eq", $EQ = "$eq",
$NEQ = "$ne", $NEQ = "$ne",
$GLOB = "$glob" $GLOB = "$glob",
$ELEMENTMATCH = "$elemMatch"
} }
+2 -1
View File
@@ -1391,7 +1391,8 @@ export const registerRoutes = async (
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
projectGatewayDAL projectGatewayDAL,
resourceMetadataDAL
}); });
const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({
@@ -11,6 +11,7 @@ import {
UsersSchema UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
import { UnpackedPermissionSchema } from "./sanitizedSchema/permission"; import { UnpackedPermissionSchema } from "./sanitizedSchema/permission";
@@ -232,7 +233,11 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({
inputIV: true, inputIV: true,
inputTag: true, inputTag: true,
algorithm: true algorithm: true
}); }).merge(
z.object({
metadata: ResourceMetadataSchema.optional()
})
);
export const SanitizedAuditLogStreamSchema = z.object({ export const SanitizedAuditLogStreamSchema = z.object({
id: z.string(), id: z.string(),
@@ -1,13 +1,9 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { z } from "zod"; import { z } from "zod";
import { ActionProjectType, SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; import { SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
ProjectPermissionDynamicSecretActions,
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { DASHBOARD } from "@app/lib/api-docs"; import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
@@ -317,24 +313,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalCount: totalFolderCount ?? 0 totalCount: totalFolderCount ?? 0
}; };
const { permission } = await server.services.permission.getProjectPermission({ if (includeDynamicSecrets) {
actor: req.permission.type,
actorId: req.permission.id,
projectId,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
actionProjectType: ActionProjectType.SecretManager
});
const allowedDynamicSecretEnvironments = // filter envs user has access to
environments.filter((environment) =>
permission.can(
ProjectPermissionDynamicSecretActions.Lease,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
)
);
if (includeDynamicSecrets && allowedDynamicSecretEnvironments.length) {
// this is the unique count, ie duplicate secrets across envs only count as 1 // this is the unique count, ie duplicate secrets across envs only count as 1
totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({ totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({
actor: req.permission.type, actor: req.permission.type,
@@ -343,7 +322,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId, projectId,
search, search,
environmentSlugs: allowedDynamicSecretEnvironments, environmentSlugs: environments,
path: secretPath, path: secretPath,
isInternal: true isInternal: true
}); });
@@ -358,7 +337,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
search, search,
orderBy, orderBy,
orderDirection, orderDirection,
environmentSlugs: allowedDynamicSecretEnvironments, environmentSlugs: environments,
path: secretPath, path: secretPath,
limit: remainingLimit, limit: remainingLimit,
offset: adjustedOffset, offset: adjustedOffset,
@@ -35,6 +35,10 @@ Create a user with the required permission in your SQL instance. This user will
Maximum time-to-live for a generated secret Maximum time-to-live for a generated secret
</ParamField> </ParamField>
<ParamField path="Metadata" type="list" required>
List of key/value metadata pairs
</ParamField>
<ParamField path="Service" type="string" required> <ParamField path="Service" type="string" required>
Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**.
</ParamField> </ParamField>
@@ -34,6 +34,10 @@ Create a user with the required permission in your SQL instance. This user will
Maximum time-to-live for a generated secret Maximum time-to-live for a generated secret
</ParamField> </ParamField>
<ParamField path="Metadata" type="list" required>
List of key/value metadata pairs
</ParamField>
<ParamField path="Service" type="string" required> <ParamField path="Service" type="string" required>
Choose the service you want to generate dynamic secrets for. This must be selected as **MySQL**. Choose the service you want to generate dynamic secrets for. This must be selected as **MySQL**.
</ParamField> </ParamField>
@@ -34,6 +34,10 @@ Create a user with the required permission in your SQL instance. This user will
Maximum time-to-live for a generated secret Maximum time-to-live for a generated secret
</ParamField> </ParamField>
<ParamField path="Metadata" type="list" required>
List of key/value metadata pairs
</ParamField>
<ParamField path="Service" type="string" required> <ParamField path="Service" type="string" required>
Choose the service you want to generate dynamic secrets for. This must be selected as **Oracle**. Choose the service you want to generate dynamic secrets for. This must be selected as **Oracle**.
</ParamField> </ParamField>
@@ -62,7 +66,7 @@ Create a user with the required permission in your SQL instance. This user will
A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions).
</ParamField> </ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png) ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-oracle.png)
</Step> </Step>
<Step title="(Optional) Modify SQL Statements"> <Step title="(Optional) Modify SQL Statements">
@@ -35,6 +35,10 @@ Create a user with the required permission in your SQL instance. This user will
Maximum time-to-live for a generated secret Maximum time-to-live for a generated secret
</ParamField> </ParamField>
<ParamField path="Metadata" type="list" required>
List of key/value metadata pairs
</ParamField>
<ParamField path="Service" type="string" required> <ParamField path="Service" type="string" required>
Choose the service you want to generate dynamic secrets for. This must be selected as **PostgreSQL**. Choose the service you want to generate dynamic secrets for. This must be selected as **PostgreSQL**.
</ParamField> </ParamField>
@@ -63,7 +67,7 @@ Create a user with the required permission in your SQL instance. This user will
A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions).
</ParamField> </ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal.png) ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-postgresql.png)
</Step> </Step>
<Step title="(Optional) Modify SQL Statements"> <Step title="(Optional) Modify SQL Statements">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

@@ -100,7 +100,8 @@ export enum PermissionConditionOperators {
$REGEX = "$regex", $REGEX = "$regex",
$EQ = "$eq", $EQ = "$eq",
$NEQ = "$ne", $NEQ = "$ne",
$GLOB = "$glob" $GLOB = "$glob",
$ELEMENTMATCH = "$elemMatch"
} }
export type IdentityManagementSubjectFields = { export type IdentityManagementSubjectFields = {
@@ -113,7 +114,8 @@ export const formatedConditionsOperatorNames: { [K in PermissionConditionOperato
[PermissionConditionOperators.$ALL]: "contains all", [PermissionConditionOperators.$ALL]: "contains all",
[PermissionConditionOperators.$NEQ]: "not equal to", [PermissionConditionOperators.$NEQ]: "not equal to",
[PermissionConditionOperators.$GLOB]: "matches glob pattern", [PermissionConditionOperators.$GLOB]: "matches glob pattern",
[PermissionConditionOperators.$REGEX]: "matches regex pattern" [PermissionConditionOperators.$REGEX]: "matches regex pattern",
[PermissionConditionOperators.$ELEMENTMATCH]: "element matches"
}; };
export type TPermissionConditionOperators = { export type TPermissionConditionOperators = {
@@ -123,12 +125,24 @@ export type TPermissionConditionOperators = {
[PermissionConditionOperators.$NEQ]: string; [PermissionConditionOperators.$NEQ]: string;
[PermissionConditionOperators.$REGEX]: string; [PermissionConditionOperators.$REGEX]: string;
[PermissionConditionOperators.$GLOB]: string; [PermissionConditionOperators.$GLOB]: string;
[PermissionConditionOperators.$ELEMENTMATCH]: Record<
string,
Partial<TPermissionConditionOperators>
>;
}; };
export type TPermissionCondition = Record< export type TPermissionCondition = Record<
string, string,
| string | string
| { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string } | {
$in: string[];
$all: string[];
$regex: string;
$eq: string;
$ne: string;
$glob: string;
$elemMatch: Partial<TPermissionCondition>;
}
>; >;
export enum ProjectPermissionSub { export enum ProjectPermissionSub {
@@ -182,6 +196,7 @@ export type SecretFolderSubjectFields = {
export type DynamicSecretSubjectFields = { export type DynamicSecretSubjectFields = {
environment: string; environment: string;
secretPath: string; secretPath: string;
metadata?: (string | { key: string; value: string })[];
}; };
export type SecretImportSubjectFields = { export type SecretImportSubjectFields = {
@@ -13,6 +13,7 @@ export type TDynamicSecret = {
status?: DynamicSecretStatus; status?: DynamicSecretStatus;
statusDetails?: string; statusDetails?: string;
maxTTL: string; maxTTL: string;
metadata?: { key: string; value: string }[];
}; };
export enum DynamicSecretProviders { export enum DynamicSecretProviders {
@@ -261,6 +262,7 @@ export type TDynamicSecretProvider =
digits?: number; digits?: number;
}; };
}; };
export type TCreateDynamicSecretDTO = { export type TCreateDynamicSecretDTO = {
projectSlug: string; projectSlug: string;
provider: TDynamicSecretProvider; provider: TDynamicSecretProvider;
@@ -269,6 +271,7 @@ export type TCreateDynamicSecretDTO = {
path: string; path: string;
environmentSlug: string; environmentSlug: string;
name: string; name: string;
metadata?: { key: string; value: string }[];
}; };
export type TUpdateDynamicSecretDTO = { export type TUpdateDynamicSecretDTO = {
@@ -278,6 +281,7 @@ export type TUpdateDynamicSecretDTO = {
environmentSlug: string; environmentSlug: string;
data: { data: {
newName?: string; newName?: string;
metadata?: { key: string; value: string }[];
defaultTTL?: string; defaultTTL?: string;
maxTTL?: string | null; maxTTL?: string | null;
inputs?: unknown; inputs?: unknown;
@@ -0,0 +1,189 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const DynamicSecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
setValue,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions`
});
const conditionErrorMessage =
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.message ||
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.root
?.message;
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition = watch(
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}`
) as {
lhs: string;
rhs: string;
operator: string;
};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue(
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
<SelectItem value="metadataKey">Metadata Key</SelectItem>
<SelectItem value="metadataValue">Metadata Value</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{conditionErrorMessage && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{conditionErrorMessage}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -285,11 +285,59 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition
} else { } else {
Object.keys(condition).forEach((conditionOperator) => { Object.keys(condition).forEach((conditionOperator) => {
const rhs = condition[conditionOperator as PermissionConditionOperators]; const rhs = condition[conditionOperator as PermissionConditionOperators];
formConditions.push({ if (Array.isArray(rhs) || typeof rhs === "string") {
operator: conditionOperator, formConditions.push({
lhs: type, operator: conditionOperator,
rhs: typeof rhs === "string" ? rhs : rhs.join(",") lhs: type,
}); rhs: typeof rhs === "string" ? rhs : rhs.join(",")
});
} else if (
conditionOperator === PermissionConditionOperators.$ELEMENTMATCH &&
type === "metadata"
) {
const deepKeyCondition = rhs.key;
if (deepKeyCondition) {
if (typeof deepKeyCondition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: "metadataKey",
rhs: deepKeyCondition
});
} else {
Object.keys(deepKeyCondition).forEach((keyOperator) => {
const deepRhs = deepKeyCondition?.[keyOperator as PermissionConditionOperators];
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
formConditions.push({
operator: keyOperator,
lhs: "metadataKey",
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
});
}
});
}
}
const deepValueCondition = rhs.value;
if (deepValueCondition) {
if (typeof deepValueCondition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: "metadataValue",
rhs: deepValueCondition
});
} else {
Object.keys(deepValueCondition).forEach((keyOperator) => {
const deepRhs = deepValueCondition?.[keyOperator as PermissionConditionOperators];
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
formConditions.push({
operator: keyOperator,
lhs: "metadataValue",
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
});
}
});
}
}
}
}); });
} }
}); });
@@ -636,7 +684,45 @@ const convertFormOperatorToCaslCondition = (
conditions: { lhs: string; rhs: string; operator: string }[] conditions: { lhs: string; rhs: string; operator: string }[]
) => { ) => {
const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {}; const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {};
const metadataKeyCondition = conditions.find((condition) => condition.lhs === "metadataKey");
const metadataValueCondition = conditions.find((condition) => condition.lhs === "metadataValue");
if (metadataKeyCondition || metadataValueCondition) {
caslCondition.metadata = {
[PermissionConditionOperators.$ELEMENTMATCH]: {}
};
if (metadataKeyCondition) {
const operator = metadataKeyCondition.operator as PermissionConditionOperators;
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.key = {
[metadataKeyCondition.operator]: [
PermissionConditionOperators.$IN,
PermissionConditionOperators.$ALL
].includes(operator)
? metadataKeyCondition.rhs.split(",")
: metadataKeyCondition.rhs
};
}
if (metadataValueCondition) {
const operator = metadataValueCondition.operator as PermissionConditionOperators;
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.value = {
[metadataValueCondition.operator]: [
PermissionConditionOperators.$IN,
PermissionConditionOperators.$ALL
].includes(operator)
? metadataValueCondition.rhs.split(",")
: metadataValueCondition.rhs
};
}
}
conditions.forEach((el) => { conditions.forEach((el) => {
// these are special fields and handled above
if (el.lhs === "metadataKey" || el.lhs === "metadataValue") {
return;
}
if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {}; if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {};
if ( if (
el.operator === PermissionConditionOperators.$IN || el.operator === PermissionConditionOperators.$IN ||
@@ -647,7 +733,9 @@ const convertFormOperatorToCaslCondition = (
caslCondition[el.lhs][ caslCondition[el.lhs][
el.operator as Exclude< el.operator as Exclude<
PermissionConditionOperators, PermissionConditionOperators,
PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN | PermissionConditionOperators.$ALL
| PermissionConditionOperators.$IN
| PermissionConditionOperators.$ELEMENTMATCH
> >
] = el.rhs; ] = el.rhs;
} }
@@ -21,6 +21,7 @@ import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies"; import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions"; import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions";
@@ -48,6 +49,9 @@ export const renderConditionalComponents = (
if (subject === ProjectPermissionSub.Secrets) if (subject === ProjectPermissionSub.Secrets)
return <SecretPermissionConditions isDisabled={isDisabled} />; return <SecretPermissionConditions isDisabled={isDisabled} />;
if (subject === ProjectPermissionSub.DynamicSecrets)
return <DynamicSecretPermissionConditions isDisabled={isDisabled} />;
if (isConditionalSubjects(subject)) { if (isConditionalSubjects(subject)) {
if (subject === ProjectPermissionSub.Identity) { if (subject === ProjectPermissionSub.Identity) {
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />; return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
@@ -207,7 +207,8 @@ export const OverviewPage = () => {
ProjectPermissionDynamicSecretActions.CreateRootCredential, ProjectPermissionDynamicSecretActions.CreateRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { subject(ProjectPermissionSub.DynamicSecrets, {
environment: env.slug, environment: env.slug,
secretPath secretPath,
metadata: ["*"]
}) })
) )
); );
@@ -138,7 +138,7 @@ const Page = () => {
const canReadDynamicSecret = permission.can( const canReadDynamicSecret = permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath }) subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata: ["*"] })
); );
const canReadSecretRotations = permission.can( const canReadSecretRotations = permission.can(
@@ -532,7 +532,7 @@ const Page = () => {
importedBy={importedBy} importedBy={importedBy}
/> />
)} )}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />} {noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret && {!canReadSecret &&
!canReadDynamicSecret && !canReadDynamicSecret &&
!canReadSecretImports && !canReadSecretImports &&
@@ -864,7 +864,8 @@ export const ActionBar = ({
environment, environment,
secretPath, secretPath,
secretName: "*", secretName: "*",
secretTags: ["*"] secretTags: ["*"],
metadata: ["*"]
})} })}
> >
{(isAllowed) => ( {(isAllowed) => (
@@ -25,6 +25,8 @@ import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types"; import { WorkspaceEnv } from "@app/hooks/api/types";
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
const passwordRequirementsSchema = z const passwordRequirementsSchema = z
.object({ .object({
length: z.number().min(1).max(250), length: z.number().min(1).max(250),
@@ -82,8 +84,16 @@ const formSchema = z.object({
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); 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"), name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() }) environment: z.object({ name: z.string(), slug: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().default("")
})
.array()
.optional()
}); });
type TForm = z.infer<typeof formSchema>; type TForm = z.infer<typeof formSchema>;
type Props = { type Props = {
@@ -192,7 +202,8 @@ export const SqlDatabaseInputForm = ({
maxTTL, maxTTL,
provider, provider,
defaultTTL, defaultTTL,
environment environment,
metadata
}: TForm) => { }: TForm) => {
// wait till previous request is finished // wait till previous request is finished
if (createDynamicSecret.isPending) return; if (createDynamicSecret.isPending) return;
@@ -205,7 +216,8 @@ export const SqlDatabaseInputForm = ({
path: secretPath, path: secretPath,
defaultTTL, defaultTTL,
projectSlug, projectSlug,
environmentSlug: environment.slug environmentSlug: environment.slug,
metadata
}); });
onCompleted(); onCompleted();
} catch { } catch {
@@ -283,46 +295,47 @@ export const SqlDatabaseInputForm = ({
/> />
</div> </div>
</div> </div>
<div> <MetadataForm control={control} />
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div> <div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200"> <div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration Configuration
</div> </div>
<div>
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div> <div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div>
<Controller <Controller
@@ -29,11 +29,13 @@ import {
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api"; import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types"; import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease"; import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
type Props = { type Props = {
dynamicSecret: TDynamicSecret;
dynamicSecretName: string; dynamicSecretName: string;
projectSlug: string; projectSlug: string;
environment: string; environment: string;
@@ -48,7 +50,8 @@ export const DynamicSecretLease = ({
environment, environment,
secretPath, secretPath,
onClickNewLease, onClickNewLease,
onClose onClose,
dynamicSecret
}: Props) => { }: Props) => {
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecret", "deleteSecret",
@@ -140,7 +143,11 @@ export const DynamicSecretLease = ({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip renderTooltip
allowedLabel="Renew" allowedLabel="Renew"
> >
@@ -159,7 +166,11 @@ export const DynamicSecretLease = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -181,7 +192,8 @@ export const DynamicSecretLease = ({
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { a={subject(ProjectPermissionSub.DynamicSecrets, {
environment, environment,
secretPath secretPath,
metadata: dynamicSecret.metadata
})} })}
renderTooltip renderTooltip
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems." allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
@@ -215,7 +227,8 @@ export const DynamicSecretLease = ({
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { a={subject(ProjectPermissionSub.DynamicSecrets, {
environment, environment,
secretPath secretPath,
metadata: dynamicSecret.metadata
})} })}
> >
{(isAllowed) => ( {(isAllowed) => (
@@ -144,7 +144,11 @@ export const DynamicSecretListView = ({
<div className="flex items-center space-x-2 px-4 py-2"> <div className="flex items-center space-x-2 px-4 py-2">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip renderTooltip
allowedLabel="Edit" allowedLabel="Edit"
> >
@@ -186,7 +190,11 @@ export const DynamicSecretListView = ({
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.EditRootCredential} I={ProjectPermissionDynamicSecretActions.EditRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip renderTooltip
allowedLabel="Edit" allowedLabel="Edit"
> >
@@ -208,7 +216,11 @@ export const DynamicSecretListView = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.DeleteRootCredential} I={ProjectPermissionDynamicSecretActions.DeleteRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -236,6 +248,7 @@ export const DynamicSecretListView = ({
className="max-w-3xl" className="max-w-3xl"
> >
<DynamicSecretLease <DynamicSecretLease
dynamicSecret={secret}
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)} onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")} onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug} projectSlug={projectSlug}
@@ -23,6 +23,8 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api"; import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { MetadataForm } from "../MetadataForm";
const passwordRequirementsSchema = z const passwordRequirementsSchema = z
.object({ .object({
length: z.number().min(1).max(250), length: z.number().min(1).max(250),
@@ -85,6 +87,13 @@ const formSchema = z.object({
newName: z newName: z
.string() .string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase") .refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional(),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().default("")
})
.array()
.optional() .optional()
}); });
type TForm = z.infer<typeof formSchema>; type TForm = z.infer<typeof formSchema>;
@@ -126,6 +135,7 @@ export const EditDynamicSecretSqlProviderForm = ({
defaultTTL: dynamicSecret.defaultTTL, defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL, maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name, newName: dynamicSecret.name,
metadata: dynamicSecret.metadata,
inputs: { inputs: {
...(dynamicSecret.inputs as TForm["inputs"]), ...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements: passwordRequirements:
@@ -147,7 +157,13 @@ export const EditDynamicSecretSqlProviderForm = ({
const isGatewayInActive = const isGatewayInActive =
projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1; projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1;
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => { const handleUpdateDynamicSecret = async ({
inputs,
maxTTL,
defaultTTL,
newName,
metadata
}: TForm) => {
// wait till previous request is finished // wait till previous request is finished
if (updateDynamicSecret.isPending) return; if (updateDynamicSecret.isPending) return;
try { try {
@@ -163,7 +179,8 @@ export const EditDynamicSecretSqlProviderForm = ({
...inputs, ...inputs,
projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId
}, },
newName: newName === dynamicSecret.name ? undefined : newName newName: newName === dynamicSecret.name ? undefined : newName,
metadata
} }
}); });
onClose(); onClose();
@@ -229,46 +246,50 @@ export const EditDynamicSecretSqlProviderForm = ({
/> />
</div> </div>
</div> </div>
<div> <MetadataForm control={control} />
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem value={null as unknown as string} onClick={() => onChange(undefined)}>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div> <div>
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div> <div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
<div>
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<Controller <Controller
control={control} control={control}
@@ -0,0 +1,76 @@
import { Control, Controller, useFieldArray } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2";
export const MetadataForm = ({ control }: { control: Control<any> }) => {
const metadataFormFields = useFieldArray({
control,
name: "metadata"
});
return (
<FormControl label="Metadata">
<div className="flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} className="max-h-8" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`metadata.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} className="max-h-8" />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 max-h-8"
variant="outline_bg"
onClick={() => metadataFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className={`${metadataFormFields.fields.length > 0 ? "pt-2" : ""}`}>
<IconButton
ariaLabel="Add Key"
variant="outline_bg"
size="xs"
className="rounded-md"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
</div>
</FormControl>
);
};