Compare commits

...

5 Commits

41 changed files with 756 additions and 316 deletions

View File

@ -0,0 +1,91 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
// ? ACCESS APPROVALS
const accessApprovalPolicyHasSecretPathColumn = await knex.schema.hasColumn(
TableName.AccessApprovalPolicy,
"secretPath"
);
const accessApprovalPolicyHasNewSecretPathsColumn = await knex.schema.hasColumn(
TableName.AccessApprovalPolicy,
"secretPaths"
);
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
if (!accessApprovalPolicyHasNewSecretPathsColumn) {
t.jsonb("secretPaths").notNullable().defaultTo("[]");
}
});
if (accessApprovalPolicyHasSecretPathColumn) {
// Move the existing secretPath values to the new secretPaths column
await knex(TableName.AccessApprovalPolicy)
.select("id", "secretPath")
.whereNotNull("secretPath")
.whereNot("secretPath", "")
.update({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- secretPaths are not in the type definition yet
secretPaths: knex.raw("to_jsonb(ARRAY[??])", ["secretPath"])
});
}
// TODO(daniel): Drop the secretPath column in the future when this has stabilized
// ? SECRET CHANGE APPROVALS
const secretChangeApprovalPolicyHasSecretPathColumn = await knex.schema.hasColumn(
TableName.SecretApprovalPolicy,
"secretPath"
);
const secretChangeApprovalPolicyHasNewSecretPathsColumn = await knex.schema.hasColumn(
TableName.SecretApprovalPolicy,
"secretPaths"
);
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
if (!secretChangeApprovalPolicyHasNewSecretPathsColumn) {
t.jsonb("secretPaths").notNullable().defaultTo("[]");
}
});
if (secretChangeApprovalPolicyHasSecretPathColumn) {
// Move the existing secretPath values to the new secretPaths column
await knex(TableName.SecretApprovalPolicy)
.select("id", "secretPath")
.whereNotNull("secretPath")
.whereNot("secretPath", "")
.update({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- secretPaths are not in the type definition yet
secretPaths: knex.raw("to_jsonb(ARRAY[??])", ["secretPath"])
});
}
// TODO(daniel): Drop the secretPath column in the future when this has stabilized.
}
export async function down(knex: Knex): Promise<void> {
// TODO(daniel): Restore the secretPath columns when we add dropping in the up migration. (needs to be re-filled with data from the `secretPaths` column)
const accessApprovalPolicyHasNewSecretsPathsColumn = await knex.schema.hasColumn(
TableName.AccessApprovalPolicy,
"secretPaths"
);
const secretChangeApprovalPolicyHasSecretPathColumn = await knex.schema.hasColumn(
TableName.SecretApprovalPolicy,
"secretPaths"
);
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
if (accessApprovalPolicyHasNewSecretsPathsColumn) {
t.dropColumn("secretPaths");
}
});
await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => {
if (secretChangeApprovalPolicyHasSecretPathColumn) {
t.dropColumn("secretPaths");
}
});
}

View File

@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({
envId: z.string().uuid(), envId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard") enforcementLevel: z.string().default("hard"),
secretPaths: z.unknown()
}); });
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>; export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;

View File

@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const IdentityMetadataSchema = z.object({ export const IdentityMetadataSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
key: z.string(), key: z.string(),
value: z.string(), value: z.string().nullable().optional(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
userId: z.string().uuid().nullable().optional(), userId: z.string().uuid().nullable().optional(),
identityId: z.string().uuid().nullable().optional(), identityId: z.string().uuid().nullable().optional(),

View File

@ -12,7 +12,7 @@ import { TImmutableDBKeys } from "./models";
export const KmsRootConfigSchema = z.object({ export const KmsRootConfigSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
encryptedRootKey: zodBuffer, encryptedRootKey: zodBuffer,
encryptionStrategy: z.string(), encryptionStrategy: z.string().default("SOFTWARE").nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date()
}); });

View File

@ -20,7 +20,8 @@ export const ProjectUserAdditionalPrivilegeSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
userId: z.string().uuid(), userId: z.string().uuid(),
projectId: z.string() projectId: z.string(),
accessRequestId: z.string().uuid().nullable().optional()
}); });
export type TProjectUserAdditionalPrivilege = z.infer<typeof ProjectUserAdditionalPrivilegeSchema>; export type TProjectUserAdditionalPrivilege = z.infer<typeof ProjectUserAdditionalPrivilegeSchema>;

View File

@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({
envId: z.string().uuid(), envId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard") enforcementLevel: z.string().default("hard"),
secretPaths: z.unknown()
}); });
export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>; export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;

View File

@ -2,6 +2,7 @@ import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn";
import { EnforcementLevel } from "@app/lib/types"; import { EnforcementLevel } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@ -19,7 +20,10 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
body: z.object({ body: z.object({
projectSlug: z.string().trim(), projectSlug: z.string().trim(),
name: z.string().optional(), name: z.string().optional(),
secretPath: z.string().trim().default("/"), secretPaths: z
.string()
.array()
.transform((val) => val.map((v) => prefixWithSlash(removeTrailingSlash(v)).trim())),
environment: z.string(), environment: z.string(),
approvers: z approvers: z
.discriminatedUnion("type", [ .discriminatedUnion("type", [
@ -49,6 +53,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`, name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`,
enforcementLevel: req.body.enforcementLevel enforcementLevel: req.body.enforcementLevel
}); });
return { approval }; return { approval };
} }
}); });
@ -134,11 +139,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
}), }),
body: z.object({ body: z.object({
name: z.string().optional(), name: z.string().optional(),
secretPath: z secretPaths: z.string().array().optional(),
.string()
.trim()
.optional()
.transform((val) => (val === "" ? "/" : val)),
approvers: z approvers: z
.discriminatedUnion("type", [ .discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }), z.object({ type: z.literal(ApproverType.Group), id: z.string() }),

View File

@ -20,7 +20,14 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
method: "POST", method: "POST",
schema: { schema: {
body: z.object({ body: z.object({
permissions: z.any().array(), requestedActions: z.object({
read: z.boolean(),
edit: z.boolean(),
create: z.boolean(),
delete: z.boolean()
}),
environment: z.string(),
secretPaths: z.string().array(),
isTemporary: z.boolean(), isTemporary: z.boolean(),
temporaryRange: z.string().optional() temporaryRange: z.string().optional()
}), }),
@ -39,7 +46,9 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
permissions: req.body.permissions, environment: req.body.environment,
secretPaths: req.body.secretPaths,
requestedActions: req.body.requestedActions,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectSlug: req.query.projectSlug, projectSlug: req.query.projectSlug,
temporaryRange: req.body.temporaryRange, temporaryRange: req.body.temporaryRange,
@ -107,7 +116,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
name: z.string(), name: z.string(),
approvals: z.number(), approvals: z.number(),
approvers: z.string().array(), approvers: z.string().array(),
secretPath: z.string().nullish(), secretPaths: z.string().array(),
envId: z.string(), envId: z.string(),
enforcementLevel: z.string() enforcementLevel: z.string()
}), }),

View File

@ -2,7 +2,7 @@ import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { removeTrailingSlash } from "@app/lib/fn"; import { prefixWithSlash, removeTrailingSlash } from "@app/lib/fn";
import { EnforcementLevel } from "@app/lib/types"; import { EnforcementLevel } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@ -21,12 +21,10 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
workspaceId: z.string(), workspaceId: z.string(),
name: z.string().optional(), name: z.string().optional(),
environment: z.string(), environment: z.string(),
secretPath: z secretPaths: z
.string() .string()
.optional() .array()
.nullable() .transform((val) => val.map((v) => prefixWithSlash(removeTrailingSlash(v)).trim())),
.default("/")
.transform((val) => (val ? removeTrailingSlash(val) : val)),
approvers: z approvers: z
.discriminatedUnion("type", [ .discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }), z.object({ type: z.literal(ApproverType.Group), id: z.string() }),
@ -55,6 +53,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`, name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`,
enforcementLevel: req.body.enforcementLevel enforcementLevel: req.body.enforcementLevel
}); });
return { approval }; return { approval };
} }
}); });
@ -79,12 +78,12 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1), approvals: z.number().min(1).default(1),
secretPath: z secretPaths: z
.string() .string()
.array()
.optional() .optional()
.nullable() .transform((val) => (val ? val.map((v) => prefixWithSlash(removeTrailingSlash(v)).trim()) : val)),
.transform((val) => (val ? removeTrailingSlash(val) : val))
.transform((val) => (val === "" ? "/" : val)),
enforcementLevel: z.nativeEnum(EnforcementLevel).optional() enforcementLevel: z.nativeEnum(EnforcementLevel).optional()
}), }),
response: { response: {

View File

@ -41,8 +41,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
response: { response: {
200: z.object({ 200: z.object({
approvals: SecretApprovalRequestsSchema.extend({ approvals: SecretApprovalRequestsSchema.extend({
// secretPath: z.string(),
policy: z.object({ policy: z.object({
secretPaths: z.string().array(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
approvals: z.number(), approvals: z.number(),
@ -51,7 +51,6 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
userId: z.string().nullable().optional() userId: z.string().nullable().optional()
}) })
.array(), .array(),
secretPath: z.string().optional().nullable(),
enforcementLevel: z.string() enforcementLevel: z.string()
}), }),
committerUser: approvalRequestUser, committerUser: approvalRequestUser,
@ -253,13 +252,12 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
200: z.object({ 200: z.object({
approval: SecretApprovalRequestsSchema.merge( approval: SecretApprovalRequestsSchema.merge(
z.object({ z.object({
// secretPath: z.string(),
policy: z.object({ policy: z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
approvals: z.number(), approvals: z.number(),
approvers: approvalRequestUser.array(), approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(), secretPaths: z.string().array(),
enforcementLevel: z.string() enforcementLevel: z.string()
}), }),
environment: z.string(), environment: z.string(),
@ -308,6 +306,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
id: req.params.id id: req.params.id
}); });
return { approval }; return { approval };
} }
}); });

View File

@ -17,15 +17,21 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>, filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>,
customFilter?: { customFilter?: {
policyId?: string; policyId?: string;
secretPaths?: string[];
} }
) => { ) => {
const result = await tx(TableName.AccessApprovalPolicy) const query = tx(TableName.AccessApprovalPolicy)
// eslint-disable-next-line // eslint-disable-next-line
.where(buildFindFilter(filter)) .where(buildFindFilter(filter))
.where((qb) => { .where((qb) => {
if (customFilter?.policyId) { if (customFilter?.policyId) {
void qb.where(`${TableName.AccessApprovalPolicy}.id`, "=", customFilter.policyId); void qb.where(`${TableName.AccessApprovalPolicy}.id`, "=", customFilter.policyId);
} }
if (customFilter?.secretPaths) {
void qb.whereRaw(`${TableName.AccessApprovalPolicy}.secretPaths @> ?::jsonb`, [
JSON.stringify(customFilter.secretPaths)
]);
}
}) })
.join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
.leftJoin( .leftJoin(
@ -43,6 +49,51 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
.select(tx.ref("projectId").withSchema(TableName.Environment)) .select(tx.ref("projectId").withSchema(TableName.Environment))
.select(selectAllTableCols(TableName.AccessApprovalPolicy)); .select(selectAllTableCols(TableName.AccessApprovalPolicy));
const result = await query;
return result;
};
const accessApprovalPolicyFindOneQuery = async (
tx: Knex,
filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>,
customFilter?: {
policyId?: string;
secretPaths?: string[];
}
) => {
const query = tx(TableName.AccessApprovalPolicy)
// eslint-disable-next-line
.where(buildFindFilter(filter))
.where((qb) => {
if (customFilter?.policyId) {
void qb.where(`${TableName.AccessApprovalPolicy}.id`, "=", customFilter.policyId);
}
if (customFilter?.secretPaths) {
void qb.whereRaw(`${TableName.AccessApprovalPolicy}."secretPaths" = ?::jsonb`, [
JSON.stringify(customFilter.secretPaths)
]);
}
})
.join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
.leftJoin(
TableName.AccessApprovalPolicyApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId`
)
.leftJoin(TableName.Users, `${TableName.AccessApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`)
.select(tx.ref("username").withSchema(TableName.Users).as("approverUsername"))
.select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
.select(tx.ref("id").withSchema(TableName.Environment).as("envId"))
.select(tx.ref("projectId").withSchema(TableName.Environment))
.select(selectAllTableCols(TableName.AccessApprovalPolicy))
.first();
const result = await query;
return result; return result;
}; };
@ -109,8 +160,8 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
slug: data.envSlug slug: data.envSlug
}, },
projectId: data.projectId, projectId: data.projectId,
...AccessApprovalPoliciesSchema.parse(data) ...AccessApprovalPoliciesSchema.parse(data),
// secretPath: data.secretPath || undefined, secretPaths: data.secretPaths as string[]
}), }),
childrenMapper: [ childrenMapper: [
{ {
@ -139,5 +190,52 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
} }
}; };
return { ...accessApprovalPolicyOrm, find, findById }; const findOne = async (
filter: Partial<Omit<TAccessApprovalPolicies, "secretPaths">>,
customFilter?: { secretPaths?: string[] },
tx?: Knex
) => {
const doc = await accessApprovalPolicyFindOneQuery(tx || db.replicaNode(), filter, customFilter);
if (!doc) {
return null;
}
const [formattedDoc] = sqlNestRelationships({
data: [doc],
key: "id",
parentMapper: (data) => ({
environment: {
id: data.envId,
name: data.envName,
slug: data.envSlug
},
projectId: data.projectId,
...AccessApprovalPoliciesSchema.parse(data)
}),
childrenMapper: [
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId: id, approverUsername }) => ({
id,
type: ApproverType.User,
name: approverUsername
})
},
{
key: "approverGroupId",
label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({
id,
type: ApproverType.Group
})
}
]
});
return formattedDoc;
};
return { ...accessApprovalPolicyOrm, find, findById, findOne };
}; };

View File

@ -48,7 +48,7 @@ export const accessApprovalPolicyServiceFactory = ({
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
secretPath, secretPaths,
actorAuthMethod, actorAuthMethod,
approvals, approvals,
approvers, approvers,
@ -138,7 +138,7 @@ export const accessApprovalPolicyServiceFactory = ({
{ {
envId: env.id, envId: env.id,
approvals, approvals,
secretPath, secretPaths: JSON.stringify(secretPaths),
name, name,
enforcementLevel enforcementLevel
}, },
@ -166,7 +166,7 @@ export const accessApprovalPolicyServiceFactory = ({
return doc; return doc;
}); });
return { ...accessApproval, environment: env, projectId: project.id }; return { ...accessApproval, environment: env, projectId: project.id, secretPaths };
}; };
const getAccessApprovalPolicyByProjectSlug = async ({ const getAccessApprovalPolicyByProjectSlug = async ({
@ -190,13 +190,14 @@ export const accessApprovalPolicyServiceFactory = ({
// ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id });
return accessApprovalPolicies; return accessApprovalPolicies;
}; };
const updateAccessApprovalPolicy = async ({ const updateAccessApprovalPolicy = async ({
policyId, policyId,
approvers, approvers,
secretPath, secretPaths,
name, name,
actorId, actorId,
actor, actor,
@ -246,7 +247,7 @@ export const accessApprovalPolicyServiceFactory = ({
accessApprovalPolicy.id, accessApprovalPolicy.id,
{ {
approvals, approvals,
secretPath, secretPaths: JSON.stringify(secretPaths),
name, name,
enforcementLevel enforcementLevel
}, },
@ -321,13 +322,14 @@ export const accessApprovalPolicyServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
ProjectPermissionSub.SecretApproval ProjectPermissionSub.SecretApproval
); );
await accessApprovalPolicyDAL.deleteById(policyId); await accessApprovalPolicyDAL.deleteById(policyId);
return policy; return { ...policy, secretPaths: policy.secretPaths as string[] };
}; };
const getAccessPolicyCountByEnvSlug = async ({ const getAccessPolicyCountByEnvSlug = async ({

View File

@ -20,7 +20,7 @@ export enum ApproverType {
export type TCreateAccessApprovalPolicy = { export type TCreateAccessApprovalPolicy = {
approvals: number; approvals: number;
secretPath: string; secretPaths: string[];
environment: string; environment: string;
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
projectSlug: string; projectSlug: string;
@ -32,7 +32,7 @@ export type TUpdateAccessApprovalPolicy = {
policyId: string; policyId: string;
approvals?: number; approvals?: number;
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
secretPath?: string; secretPaths?: string[];
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;

View File

@ -59,7 +59,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"),
db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("secretPaths").withSchema(TableName.AccessApprovalPolicy).as("policySecretPaths"),
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId")
) )
@ -78,7 +78,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus") db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")
) )
// TODO: ADD SUPPORT FOR GROUPS!!!!
.select( .select(
db.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"), db.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"),
db.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"), db.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"),
@ -116,9 +115,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
id: doc.policyId, id: doc.policyId,
name: doc.policyName, name: doc.policyName,
approvals: doc.policyApprovals, approvals: doc.policyApprovals,
secretPath: doc.policySecretPath,
enforcementLevel: doc.policyEnforcementLevel, enforcementLevel: doc.policyEnforcementLevel,
envId: doc.policyEnvId envId: doc.policyEnvId,
secretPaths: doc.policySecretPaths as string[]
}, },
requestedByUser: { requestedByUser: {
userId: doc.requestedByUserId, userId: doc.requestedByUserId,
@ -250,7 +249,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
tx.ref("projectId").withSchema(TableName.Environment), tx.ref("projectId").withSchema(TableName.Environment),
tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("secretPaths").withSchema(TableName.AccessApprovalPolicy).as("policySecretPaths"),
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals") tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals")
); );
@ -270,8 +269,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
id: el.policyId, id: el.policyId,
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel,
enforcementLevel: el.policyEnforcementLevel secretPaths: el.policySecretPaths as string[]
}, },
requestedByUser: { requestedByUser: {
userId: el.requestedByUserId, userId: el.requestedByUserId,

View File

@ -4,11 +4,7 @@ import { BadRequestError } from "@app/lib/errors";
import { TVerifyPermission } from "./access-approval-request-types"; import { TVerifyPermission } from "./access-approval-request-types";
function filterUnique(value: string, index: number, array: string[]) { export const verifyRequestedPermissions = ({ permissions, checkPath }: TVerifyPermission) => {
return array.indexOf(value) === index;
}
export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => {
const permission = unpackRules( const permission = unpackRules(
permissions as PackRule<{ permissions as PackRule<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -22,32 +18,20 @@ export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) =
throw new BadRequestError({ message: "No permission provided" }); throw new BadRequestError({ message: "No permission provided" });
} }
const requestedPermissions: string[] = []; for (const perm of permission) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment
const permissionEnv = perm.conditions?.environment;
for (const p of permission) { if (!permissionEnv || typeof permissionEnv !== "string") {
if (p.action[0] === "read") requestedPermissions.push("Read Access"); throw new BadRequestError({ message: "Permission environment is not a string" });
if (p.action[0] === "create") requestedPermissions.push("Create Access"); }
if (p.action[0] === "delete") requestedPermissions.push("Delete Access");
if (p.action[0] === "edit") requestedPermissions.push("Edit Access"); if (checkPath) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const permissionSecretPath = perm.conditions?.secretPath?.$glob;
if (!permissionSecretPath || typeof permissionSecretPath !== "string") {
throw new BadRequestError({ message: "Permission path is not a string" });
}
}
} }
const firstPermission = permission[0];
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const permissionSecretPath = firstPermission.conditions?.secretPath?.$glob;
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment
const permissionEnv = firstPermission.conditions?.environment;
if (!permissionEnv || typeof permissionEnv !== "string") {
throw new BadRequestError({ message: "Permission environment is not a string" });
}
if (!permissionSecretPath || typeof permissionSecretPath !== "string") {
throw new BadRequestError({ message: "Permission path is not a string" });
}
return {
envSlug: permissionEnv,
secretPath: permissionSecretPath,
accessTypes: requestedPermissions.filter(filterUnique)
};
}; };

View File

@ -1,3 +1,4 @@
import { packRules } from "@casl/ability/extra";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import ms from "ms"; import ms from "ms";
@ -19,6 +20,7 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli
import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
import { TGroupDALFactory } from "../group/group-dal"; import { TGroupDALFactory } from "../group/group-dal";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal";
@ -89,7 +91,9 @@ export const accessApprovalRequestServiceFactory = ({
isTemporary, isTemporary,
temporaryRange, temporaryRange,
actorId, actorId,
permissions: requestedPermissions, environment: envSlug,
secretPaths,
requestedActions,
actor, actor,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
@ -116,18 +120,75 @@ export const accessApprovalRequestServiceFactory = ({
await projectDAL.checkProjectUpgradeStatus(project.id); await projectDAL.checkProjectUpgradeStatus(project.id);
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ permissions: requestedPermissions }); const requestedPermissions: unknown[] = [];
const actions = [
{ action: ProjectPermissionActions.Read, allowed: requestedActions.read },
{ action: ProjectPermissionActions.Create, allowed: requestedActions.create },
{ action: ProjectPermissionActions.Delete, allowed: requestedActions.delete },
{ action: ProjectPermissionActions.Edit, allowed: requestedActions.edit }
];
const enabledActions = actions
.filter(({ allowed }) => allowed)
.map(({ action }) => action[0].toUpperCase() + action.slice(1));
if (secretPaths.length) {
for (const secretPath of secretPaths) {
const permission = packRules(
actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions: {
environment: envSlug,
secretPath: {
$glob: secretPath
}
}
}))
);
verifyRequestedPermissions({ permissions: permission });
requestedPermissions.push(...permission);
}
} else {
const permission = packRules(
actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions: {
environment: envSlug
}
}))
);
// We disable path checking here as there will be no path to check (full environment access)
verifyRequestedPermissions({ permissions: permission, checkPath: false });
requestedPermissions.push(...permission);
}
const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug });
if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` });
const policy = await accessApprovalPolicyDAL.findOne({ const policy = await accessApprovalPolicyDAL.findOne(
envId: environment.id, {
secretPath envId: environment.id
}); },
{
secretPaths: secretPaths?.length ? secretPaths : []
}
);
if (!policy) { if (!policy) {
throw new NotFoundError({ throw new NotFoundError({
message: `No policy in environment with slug '${environment.slug}' and with secret path '${secretPath}' was found.` message: `No policy in environment with slug '${environment.slug}' and with secret paths '${secretPaths?.join(
", "
)}' was found.`
}); });
} }
@ -224,9 +285,9 @@ export const accessApprovalRequestServiceFactory = ({
requesterFullName, requesterFullName,
isTemporary, isTemporary,
requesterEmail: requestedByUser.email as string, requesterEmail: requestedByUser.email as string,
secretPath, secretPath: secretPaths?.join(", ") || "",
environment: envSlug, environment: envSlug,
permissions: accessTypes, permissions: enabledActions,
approvalUrl approvalUrl
} }
} }
@ -244,9 +305,9 @@ export const accessApprovalRequestServiceFactory = ({
...(isTemporary && { ...(isTemporary && {
expiresIn: ms(ms(temporaryRange || ""), { long: true }) expiresIn: ms(ms(temporaryRange || ""), { long: true })
}), }),
secretPath, secretPath: secretPaths?.join(", ") || "",
environment: envSlug, environment: envSlug,
permissions: accessTypes, permissions: enabledActions,
approvalUrl approvalUrl
}, },
template: SmtpTemplates.AccessApprovalRequest template: SmtpTemplates.AccessApprovalRequest

View File

@ -8,6 +8,7 @@ export enum ApprovalStatus {
export type TVerifyPermission = { export type TVerifyPermission = {
permissions: unknown; permissions: unknown;
checkPath?: boolean;
}; };
export type TGetAccessRequestCountDTO = { export type TGetAccessRequestCountDTO = {
@ -21,7 +22,15 @@ export type TReviewAccessRequestDTO = {
export type TCreateAccessApprovalRequestDTO = { export type TCreateAccessApprovalRequestDTO = {
projectSlug: string; projectSlug: string;
permissions: unknown; environment: string;
// permissions: unknown;
requestedActions: {
read: boolean;
edit: boolean;
create: boolean;
delete: boolean;
};
secretPaths: string[];
isTemporary: boolean; isTemporary: boolean;
temporaryRange?: string; temporaryRange?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;

View File

@ -232,7 +232,7 @@ export const permissionServiceFactory = ({
objectify( objectify(
userProjectPermission.metadata, userProjectPermission.metadata,
(i) => i.key, (i) => i.key,
(i) => i.value (i) => i.value || ""
) )
); );
const interpolateRules = templatedRules( const interpolateRules = templatedRules(
@ -299,7 +299,7 @@ export const permissionServiceFactory = ({
objectify( objectify(
identityProjectPermission.metadata, identityProjectPermission.metadata,
(i) => i.key, (i) => i.key,
(i) => i.value (i) => i.value || ""
) )
); );

View File

@ -135,7 +135,8 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
parentMapper: (data) => ({ parentMapper: (data) => ({
environment: { id: data.envId, name: data.envName, slug: data.envSlug }, environment: { id: data.envId, name: data.envName, slug: data.envSlug },
projectId: data.projectId, projectId: data.projectId,
...SecretApprovalPoliciesSchema.parse(data) ...SecretApprovalPoliciesSchema.parse(data),
secretPaths: data.secretPaths as string[]
}), }),
childrenMapper: [ childrenMapper: [
{ {

View File

@ -22,10 +22,22 @@ import {
TUpdateSapDTO TUpdateSapDTO
} from "./secret-approval-policy-types"; } from "./secret-approval-policy-types";
const getPolicyScore = (policy: { secretPath?: string | null }) => /*
// if glob pattern score is 1, if not exist score is 0 and if its not both then its exact path meaning score 2 * '1': The secret path is a glob pattern
// eslint-disable-next-line * '0': The secret path is not defined (whole environment is scoped)
policy.secretPath ? (containsGlobPatterns(policy.secretPath) ? 1 : 2) : 0; * '2': The secret path is an exact path
*/
const getPolicyScore = (policy: { secretPaths: string[] }) => {
let score = 0;
if (!policy.secretPaths.length) return 0;
for (const secretPath of policy.secretPaths) {
score += containsGlobPatterns(secretPath) ? 1 : 2;
}
return score;
};
type TSecretApprovalPolicyServiceFactoryDep = { type TSecretApprovalPolicyServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@ -55,7 +67,7 @@ export const secretApprovalPolicyServiceFactory = ({
approvals, approvals,
approvers, approvers,
projectId, projectId,
secretPath, secretPaths,
environment, environment,
enforcementLevel enforcementLevel
}: TCreateSapDTO) => { }: TCreateSapDTO) => {
@ -105,7 +117,7 @@ export const secretApprovalPolicyServiceFactory = ({
{ {
envId: env.id, envId: env.id,
approvals, approvals,
secretPath, secretPaths: JSON.stringify(secretPaths),
name, name,
enforcementLevel enforcementLevel
}, },
@ -153,12 +165,12 @@ export const secretApprovalPolicyServiceFactory = ({
return doc; return doc;
}); });
return { ...secretApproval, environment: env, projectId }; return { ...secretApproval, environment: env, projectId, secretPaths };
}; };
const updateSecretApprovalPolicy = async ({ const updateSecretApprovalPolicy = async ({
approvers, approvers,
secretPath, secretPaths,
name, name,
actorId, actorId,
actor, actor,
@ -209,7 +221,7 @@ export const secretApprovalPolicyServiceFactory = ({
secretApprovalPolicy.id, secretApprovalPolicy.id,
{ {
approvals, approvals,
secretPath, secretPaths: secretPaths ? JSON.stringify(secretPaths) : undefined,
name, name,
enforcementLevel enforcementLevel
}, },
@ -261,7 +273,7 @@ export const secretApprovalPolicyServiceFactory = ({
); );
} }
return doc; return { ...doc, secretPaths: doc.secretPaths as string[] };
}); });
return { return {
...updatedSap, ...updatedSap,
@ -302,7 +314,7 @@ export const secretApprovalPolicyServiceFactory = ({
} }
await secretApprovalPolicyDAL.deleteById(secretPolicyId); await secretApprovalPolicyDAL.deleteById(secretPolicyId);
return sapPolicy; return { ...sapPolicy, secretPaths: sapPolicy.secretPaths as string[] };
}; };
const getSecretApprovalPolicyByProjectId = async ({ const getSecretApprovalPolicyByProjectId = async ({
@ -325,8 +337,9 @@ export const secretApprovalPolicyServiceFactory = ({
return sapPolicies; return sapPolicies;
}; };
const getSecretApprovalPolicy = async (projectId: string, environment: string, path: string) => { const getSecretApprovalPolicy = async (projectId: string, environment: string, paths: string[] | string) => {
const secretPath = removeTrailingSlash(path); const secretPaths = (Array.isArray(paths) ? paths : [paths]).map((p) => removeTrailingSlash(p).trim());
const env = await projectEnvDAL.findOne({ slug: environment, projectId }); const env = await projectEnvDAL.findOne({ slug: environment, projectId });
if (!env) { if (!env) {
throw new NotFoundError({ throw new NotFoundError({
@ -336,14 +349,24 @@ export const secretApprovalPolicyServiceFactory = ({
const policies = await secretApprovalPolicyDAL.find({ envId: env.id }); const policies = await secretApprovalPolicyDAL.find({ envId: env.id });
if (!policies.length) return; if (!policies.length) return;
// this will filter policies either without scoped to secret path or the one that matches with secret path
const policiesFilteredByPath = policies.filter( // A policy matches if either:
({ secretPath: policyPath }) => !policyPath || picomatch.isMatch(secretPath, policyPath, { strictSlashes: false }) // 1. It has no secretPaths (applies to all paths)
); // 2. At least one of the provided secretPaths matches at least one of the policy paths
const matchingPolicies = policies.filter((policy) => {
if (!policy.secretPaths.length) return true; // Policy applies to all paths
// For each provided secret path, check if it matches any of the policy paths
return secretPaths.some((secretPath) =>
policy.secretPaths.some((policyPath) => picomatch.isMatch(secretPath, policyPath, { strictSlashes: false }))
);
});
// now sort by priority. exact secret path gets first match followed by glob followed by just env scoped // now sort by priority. exact secret path gets first match followed by glob followed by just env scoped
// if that is tie get by first createdAt // if that is tie get by first createdAt
const policiesByPriority = policiesFilteredByPath.sort((a, b) => getPolicyScore(b) - getPolicyScore(a)); const policiesByPriority = matchingPolicies.sort((a, b) => getPolicyScore(b) - getPolicyScore(a));
const finalPolicy = policiesByPriority.shift(); const finalPolicy = policiesByPriority.shift();
return finalPolicy; return finalPolicy;
}; };

View File

@ -4,7 +4,7 @@ import { ApproverType } from "../access-approval-policy/access-approval-policy-t
export type TCreateSapDTO = { export type TCreateSapDTO = {
approvals: number; approvals: number;
secretPath?: string | null; secretPaths: string[];
environment: string; environment: string;
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
projectId: string; projectId: string;
@ -15,7 +15,7 @@ export type TCreateSapDTO = {
export type TUpdateSapDTO = { export type TUpdateSapDTO = {
secretPolicyId: string; secretPolicyId: string;
approvals?: number; approvals?: number;
secretPath?: string | null; secretPaths?: string[];
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;

View File

@ -108,7 +108,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
tx.ref("projectId").withSchema(TableName.Environment), tx.ref("projectId").withSchema(TableName.Environment),
tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("secretPaths").withSchema(TableName.SecretApprovalPolicy).as("policySecretPaths"),
tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"),
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")
@ -145,7 +145,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
id: el.policyId, id: el.policyId,
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPaths: el.policySecretPaths as string[],
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId envId: el.policyEnvId
} }
@ -323,7 +323,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.raw( db.raw(
`DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank` `DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank`
), ),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("secretPaths").withSchema(TableName.SecretApprovalPolicy).as("policySecretPaths"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
@ -352,7 +352,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
id: el.policyId, id: el.policyId,
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPaths: el.policySecretPaths as string[],
enforcementLevel: el.policyEnforcementLevel enforcementLevel: el.policyEnforcementLevel
}, },
committerUser: { committerUser: {
@ -470,7 +470,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.raw( db.raw(
`DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank` `DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank`
), ),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("secretPaths").withSchema(TableName.SecretApprovalPolicy).as("policySecretPaths"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
@ -499,7 +499,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
id: el.policyId, id: el.policyId,
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPaths: el.policySecretPaths as string[],
enforcementLevel: el.policyEnforcementLevel enforcementLevel: el.policyEnforcementLevel
}, },
committerUser: { committerUser: {

View File

@ -294,10 +294,10 @@ export const secretApprovalRequestServiceFactory = ({
: undefined : undefined
})); }));
} }
const secretPath = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [ const [secretPath] = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [
secretApprovalRequest.folderId secretApprovalRequest.folderId
]); ]);
return { ...secretApprovalRequest, secretPath: secretPath?.[0]?.path || "/", commits: secrets }; return { ...secretApprovalRequest, secretPath: secretPath?.path || "/", commits: secrets };
}; };
const reviewApproval = async ({ const reviewApproval = async ({
@ -831,7 +831,7 @@ export const secretApprovalRequestServiceFactory = ({
requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`, requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`,
requesterEmail: requestedByUser.email, requesterEmail: requestedByUser.email,
bypassReason, bypassReason,
secretPath: policy.secretPath, secretPath: folder.path,
environment: env.name, environment: env.name,
approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval` approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval`
}, },

View File

@ -56,16 +56,15 @@ export const DefaultResponseErrorsSchema = {
}) })
}; };
export const sapPubSchema = SecretApprovalPoliciesSchema.merge( export const sapPubSchema = SecretApprovalPoliciesSchema.extend({
z.object({ secretPaths: z.string().array(),
environment: z.object({ environment: z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
slug: z.string() slug: z.string()
}), }),
projectId: z.string() projectId: z.string()
}) });
);
export const sanitizedServiceTokenUserSchema = UsersSchema.pick({ export const sanitizedServiceTokenUserSchema = UsersSchema.pick({
authMethods: true, authMethods: true,

View File

@ -207,7 +207,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
.object({ .object({
key: z.string().trim().min(1), key: z.string().trim().min(1),
id: z.string().trim().min(1), id: z.string().trim().min(1),
value: z.string().trim().min(1) value: z.string().trim().min(1).nullable().optional()
}) })
.array() .array()
.optional(), .optional(),

View File

@ -135,7 +135,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
.object({ .object({
key: z.string().trim().min(1), key: z.string().trim().min(1),
id: z.string().trim().min(1), id: z.string().trim().min(1),
value: z.string().trim().min(1) value: z.string().trim().min(1).nullable().optional()
}) })
.array() .array()
.optional(), .optional(),

View File

@ -1,4 +1,3 @@
import { packRules } from "@casl/ability/extra";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
@ -22,7 +21,7 @@ export const useCreateAccessApprovalPolicy = () => {
approvals, approvals,
approvers, approvers,
name, name,
secretPath, secretPaths,
enforcementLevel enforcementLevel
}) => { }) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
@ -30,7 +29,7 @@ export const useCreateAccessApprovalPolicy = () => {
projectSlug, projectSlug,
approvals, approvals,
approvers, approvers,
secretPath, secretPaths,
name, name,
enforcementLevel enforcementLevel
}); });
@ -46,11 +45,11 @@ export const useUpdateAccessApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => { mutationFn: async ({ id, approvers, approvals, name, secretPaths, enforcementLevel }) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals, approvals,
approvers, approvers,
secretPath, secretPaths,
name, name,
enforcementLevel enforcementLevel
}); });
@ -83,8 +82,8 @@ export const useCreateAccessRequest = () => {
const { data } = await apiRequest.post<TAccessApproval>( const { data } = await apiRequest.post<TAccessApproval>(
"/api/v1/access-approvals/requests", "/api/v1/access-approvals/requests",
{ {
...request, ...request
permissions: request.permissions ? packRules(request.permissions) : undefined // permissions: request.permissions ? packRules(request.permissions) : undefined
}, },
{ {
params: { params: {

View File

@ -73,7 +73,7 @@ const fetchApprovalRequests = async ({
{ params: { projectSlug, envSlug, authorProjectMembershipId } } { params: { projectSlug, envSlug, authorProjectMembershipId } }
); );
return data.requests.map((request) => ({ const result = data.requests.map((request) => ({
...request, ...request,
privilege: request.privilege privilege: request.privilege
@ -86,6 +86,8 @@ const fetchApprovalRequests = async ({
: null, : null,
permissions: unpackRules(request.permissions as unknown as PackRule<TProjectPermission>[]) permissions: unpackRules(request.permissions as unknown as PackRule<TProjectPermission>[])
})); }));
return result;
}; };
const fetchAccessRequestsCount = async (projectSlug: string) => { const fetchAccessRequestsCount = async (projectSlug: string) => {

View File

@ -6,7 +6,7 @@ export type TAccessApprovalPolicy = {
id: string; id: string;
name: string; name: string;
approvals: number; approvals: number;
secretPath: string; secretPaths: string[];
envId: string; envId: string;
workspace: string; workspace: string;
environment: WorkspaceEnv; environment: WorkspaceEnv;
@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = {
approvers?: Approver[]; approvers?: Approver[];
}; };
export enum ApproverType{ export enum ApproverType {
User = "user", User = "user",
Group = "group" Group = "group"
} }
export type Approver ={ export type Approver = {
id: string; id: string;
type: ApproverType; type: ApproverType;
} };
export type TAccessApprovalRequest = { export type TAccessApprovalRequest = {
id: string; id: string;
@ -67,7 +67,7 @@ export type TAccessApprovalRequest = {
name: string; name: string;
approvals: number; approvals: number;
approvers: string[]; approvers: string[];
secretPath?: string | null; secretPaths?: string[] | null;
envId: string; envId: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
}; };
@ -116,7 +116,18 @@ export type TProjectUserPrivilege = {
export type TCreateAccessRequestDTO = { export type TCreateAccessRequestDTO = {
projectSlug: string; projectSlug: string;
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">; secretPaths: string[];
environment: string;
requestedActions: {
read: boolean;
edit: boolean;
create: boolean;
delete: boolean;
};
} & Omit<
TProjectUserPrivilege,
"id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId" | "permissions"
>;
export type TGetAccessApprovalRequestsDTO = { export type TGetAccessApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
@ -132,7 +143,7 @@ export type TGetAccessPolicyApprovalCountDTO = {
export type TGetSecretApprovalPolicyOfBoardDTO = { export type TGetSecretApprovalPolicyOfBoardDTO = {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
secretPath: string; secretPaths: string[];
}; };
export type TCreateAccessPolicyDTO = { export type TCreateAccessPolicyDTO = {
@ -141,7 +152,7 @@ export type TCreateAccessPolicyDTO = {
environment: string; environment: string;
approvers?: Approver[]; approvers?: Approver[];
approvals?: number; approvals?: number;
secretPath?: string; secretPaths?: string[];
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
}; };
@ -149,7 +160,7 @@ export type TUpdateAccessPolicyDTO = {
id: string; id: string;
name?: string; name?: string;
approvers?: Approver[]; approvers?: Approver[];
secretPath?: string; secretPaths?: string[];
environment?: string; environment?: string;
approvals?: number; approvals?: number;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;

View File

@ -37,7 +37,7 @@ export type IdentityMembershipOrg = {
id: string; id: string;
identity: Identity; identity: Identity;
organization: string; organization: string;
metadata: { key: string; value: string; id: string }[]; metadata: { key: string; value?: string | null; id: string }[];
role: "admin" | "member" | "viewer" | "no-access" | "custom"; role: "admin" | "member" | "viewer" | "no-access" | "custom";
customRole?: TOrgRole; customRole?: TOrgRole;
createdAt: string; createdAt: string;

View File

@ -14,7 +14,7 @@ export const useCreateSecretApprovalPolicy = () => {
workspaceId, workspaceId,
approvals, approvals,
approvers, approvers,
secretPath, secretPaths,
name, name,
enforcementLevel enforcementLevel
}) => { }) => {
@ -23,7 +23,7 @@ export const useCreateSecretApprovalPolicy = () => {
workspaceId, workspaceId,
approvals, approvals,
approvers, approvers,
secretPath, secretPaths,
name, name,
enforcementLevel enforcementLevel
}); });
@ -39,11 +39,11 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ return useMutation<{}, {}, TUpdateSecretPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, secretPath, name, enforcementLevel }) => { mutationFn: async ({ id, approvers, approvals, secretPaths, name, enforcementLevel }) => {
const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals, approvals,
approvers, approvers,
secretPath, secretPaths,
name, name,
enforcementLevel enforcementLevel
}); });

View File

@ -7,22 +7,22 @@ export type TSecretApprovalPolicy = {
name: string; name: string;
envId: string; envId: string;
environment: WorkspaceEnv; environment: WorkspaceEnv;
secretPath?: string; secretPaths: string[];
approvals: number; approvals: number;
approvers: Approver[]; approvers: Approver[];
updatedAt: Date; updatedAt: Date;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
}; };
export enum ApproverType{ export enum ApproverType {
User = "user", User = "user",
Group = "group" Group = "group"
} }
export type Approver ={ export type Approver = {
id: string; id: string;
type: ApproverType; type: ApproverType;
} };
export type TGetSecretApprovalPoliciesDTO = { export type TGetSecretApprovalPoliciesDTO = {
workspaceId: string; workspaceId: string;
@ -38,7 +38,7 @@ export type TCreateSecretPolicyDTO = {
workspaceId: string; workspaceId: string;
name?: string; name?: string;
environment: string; environment: string;
secretPath?: string | null; secretPaths: string[];
approvers?: Approver[]; approvers?: Approver[];
approvals?: number; approvals?: number;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
@ -48,7 +48,7 @@ export type TUpdateSecretPolicyDTO = {
id: string; id: string;
name?: string; name?: string;
approvers?: Approver[]; approvers?: Approver[];
secretPath?: string | null; secretPaths?: string[];
approvals?: number; approvals?: number;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
// for invalidating list // for invalidating list

View File

@ -51,7 +51,7 @@ export type UserEnc = {
export type OrgUser = { export type OrgUser = {
id: string; id: string;
metadata: { key: string; value: string; id: string }[]; metadata: { key: string; value?: string | null; id: string }[];
user: { user: {
username: string; username: string;
email?: string; email?: string;

View File

@ -1,9 +1,15 @@
import { ReservedFolders } from "@app/hooks/api/secretFolders/types"; import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
export const formatReservedPaths = (secretPath: string) => { export const formatReservedPaths = (paths: string | string[]) => {
const i = secretPath.indexOf(ReservedFolders.SecretReplication); const secretPaths = Array.isArray(paths) ? paths : [paths];
if (i !== -1) {
return `${secretPath.slice(0, i)} - (replication)`; const formattedSecretPaths = secretPaths.map((secretPath) => {
} const i = secretPath?.indexOf(ReservedFolders.SecretReplication);
return secretPath; if (i !== -1) {
return `${secretPath.slice(0, i)} - (replication)`;
}
return secretPath;
});
return formattedSecretPaths.join(", ");
}; };

View File

@ -51,7 +51,7 @@ import {
import { TAccessApprovalPolicy } from "@app/hooks/api/types"; import { TAccessApprovalPolicy } from "@app/hooks/api/types";
const secretPermissionSchema = z.object({ const secretPermissionSchema = z.object({
secretPath: z.string().optional(), secretPaths: z.string().optional(),
environmentSlug: z.string(), environmentSlug: z.string(),
[ProjectPermissionActions.Edit]: z.boolean().optional(), [ProjectPermissionActions.Edit]: z.boolean().optional(),
[ProjectPermissionActions.Read]: z.boolean().optional(), [ProjectPermissionActions.Read]: z.boolean().optional(),
@ -99,7 +99,7 @@ export const SpecificPrivilegeSecretForm = ({
? { ? {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment, environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator // secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob secretPaths: privilege.permissions?.[0]?.conditions?.secretPath?.$glob
? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob) ? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob)
: "", : "",
read: privilege.permissions?.some(({ action }) => read: privilege.permissions?.some(({ action }) =>
@ -132,7 +132,7 @@ export const SpecificPrivilegeSecretForm = ({
const temporaryAccessField = privilegeForm.watch("temporaryAccess"); const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const selectedEnvironment = privilegeForm.watch("environmentSlug"); const selectedEnvironment = privilegeForm.watch("environmentSlug");
const secretPath = privilegeForm.watch("secretPath"); const secretPath = privilegeForm.watch("secretPaths");
const readAccess = privilegeForm.watch("read"); const readAccess = privilegeForm.watch("read");
const createAccess = privilegeForm.watch("create"); const createAccess = privilegeForm.watch("create");
@ -147,11 +147,11 @@ export const SpecificPrivilegeSecretForm = ({
(policy) => policy.environment.slug === selectedEnvironment (policy) => policy.environment.slug === selectedEnvironment
); );
privilegeForm.setValue("secretPath", "", { privilegeForm.setValue("secretPaths", "", {
shouldValidate: true shouldValidate: true
}); });
return [...environmentPolicies.map((policy) => policy.secretPath)]; return [...environmentPolicies.map((policy) => policy.secretPaths)];
}, [policies, selectedEnvironment]); }, [policies, selectedEnvironment]);
const isTemporary = temporaryAccessField?.isTemporary; const isTemporary = temporaryAccessField?.isTemporary;
@ -199,7 +199,7 @@ export const SpecificPrivilegeSecretForm = ({
return; return;
} }
if (!data.secretPath) { if (!data.secretPaths) {
createNotification({ createNotification({
type: "error", type: "error",
text: "Please select a secret path", text: "Please select a secret path",
@ -208,30 +208,21 @@ export const SpecificPrivilegeSecretForm = ({
return; return;
} }
const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create },
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
}
await requestAccess.mutateAsync({ await requestAccess.mutateAsync({
...data, ...data,
...(data.temporaryAccess.isTemporary && { ...(data.temporaryAccess.isTemporary && {
temporaryRange: data.temporaryAccess.temporaryRange temporaryRange: data.temporaryAccess.temporaryRange
}), }),
secretPaths: JSON.parse(data.secretPaths) as string[],
environment: data.environmentSlug,
requestedActions: {
read: data.read || false,
create: data.create || false,
edit: data.edit || false,
delete: data.delete || false
},
projectSlug: currentWorkspace.slug, projectSlug: currentWorkspace.slug,
isTemporary: data.temporaryAccess.isTemporary, isTemporary: data.temporaryAccess.isTemporary
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
}))
}); });
createNotification({ createNotification({
@ -285,7 +276,7 @@ export const SpecificPrivilegeSecretForm = ({
/> />
<Controller <Controller
control={privilegeForm.control} control={privilegeForm.control}
name="secretPath" name="secretPaths"
render={({ field }) => { render={({ field }) => {
if (policies) { if (policies) {
return ( return (
@ -294,18 +285,28 @@ export const SpecificPrivilegeSecretForm = ({
content="The selected environment doesn't have any policies." content="The selected environment doesn't have any policies."
> >
<div> <div>
<FormControl label="Secret Path"> <FormControl label="Secret Paths">
<Select <Select
{...field} {...field}
isDisabled={isMemberEditDisabled || !selectablePaths.length} isDisabled={isMemberEditDisabled || !selectablePaths.length}
className="w-48" className="w-48"
onValueChange={(e) => field.onChange(e)} onValueChange={(e) => field.onChange(e)}
> >
{selectablePaths.map((path) => ( {selectablePaths.map((paths) => {
<SelectItem value={path} key={path}> if (!paths || paths.length === 0) {
{path} return (
</SelectItem> <SelectItem value={JSON.stringify([])} key="empty">
))} Full Environment Access
</SelectItem>
);
}
return (
<SelectItem value={JSON.stringify(paths)} key={paths.join("-")}>
{paths.join(", ")}
</SelectItem>
);
})}
</Select> </Select>
</FormControl> </FormControl>
</div> </div>

View File

@ -56,7 +56,9 @@ const generateRequestText = (request: TAccessApprovalRequest, userId: string) =>
<div> <div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "} Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
{request.policy.secretPath} {request.policy.secretPaths?.length
? request.policy.secretPaths.join(", ")
: "Full environment"}
</code> </code>
in in
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
@ -120,19 +122,20 @@ export const AccessApprovalRequest = ({
projectSlug projectSlug
}); });
const { data: requests } = useGetAccessApprovalRequests({ const { data: requests, isLoading: isRequestsLoading } = useGetAccessApprovalRequests({
projectSlug, projectSlug,
authorProjectMembershipId: requestedByFilter, authorProjectMembershipId: requestedByFilter,
envSlug: envFilter envSlug: envFilter
}); });
const filteredRequests = useMemo(() => { const filteredRequests = useMemo(() => {
if (statusFilter === "open") if (statusFilter === "open") {
return requests?.filter( return requests?.filter(
(request) => (request) =>
!request.isApproved && !request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
}
if (statusFilter === "close") if (statusFilter === "close")
return requests?.filter( return requests?.filter(
(request) => (request) =>
@ -141,11 +144,10 @@ export const AccessApprovalRequest = ({
); );
return requests; return requests;
}, [requests, statusFilter, requestedByFilter, envFilter]); return [];
}, [requests, statusFilter, requestedByFilter, envFilter, isRequestsLoading]);
const generateRequestDetails = (request: TAccessApprovalRequest) => { const generateRequestDetails = (request: TAccessApprovalRequest) => {
console.log(request);
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some( const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED ({ status }) => status === ApprovalStatus.REJECTED

View File

@ -16,7 +16,7 @@ export const RequestAccessModal = ({
<ModalContent <ModalContent
className="max-w-4xl" className="max-w-4xl"
title="Request Access" title="Request Access"
subTitle="Your role has limited permissions, please contact your administrator to gain access" subTitle="Request access to specific environments and paths across the project."
> >
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} /> <SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} />
</ModalContent> </ModalContent>

View File

@ -36,7 +36,7 @@ export const ReviewAccessRequestModal = ({
const accessDetails = { const accessDetails = {
env: request.environmentName, env: request.environmentName,
// secret path will be inside $glob operator // secret path will be inside $glob operator
secretPath: request.policy.secretPath, secretPaths: request.policy.secretPaths,
read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)), read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)),
edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)), edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)),
create: request.permissions?.some(({ action }) => create: request.permissions?.some(({ action }) =>
@ -123,8 +123,16 @@ export const ReviewAccessRequestModal = ({
<div className="mt-4 mb-2 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200"> <div className="mt-4 mb-2 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
<div className="mb-1 lowercase"> <div className="mb-1 lowercase">
<span className="font-bold capitalize">Requested path: </span> <span className="font-bold capitalize">Requested environment: </span>
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge> <Badge>{accessDetails.env}</Badge>
</div>
<div className="mb-1 lowercase">
<span className="font-bold capitalize">Requested paths: </span>
<Badge>
{accessDetails?.secretPaths?.length
? accessDetails.secretPaths.join(", ")
: "Entire environment"}
</Badge>
</div> </div>
<div className="mb-1"> <div className="mb-1">
@ -142,8 +150,7 @@ export const ReviewAccessRequestModal = ({
<Button <Button
isLoading={isLoading === "approved"} isLoading={isLoading === "approved"}
isDisabled={ isDisabled={
!!isLoading || !!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
(!request.isApprover && !byPassApproval && isSoftEnforcement)
} }
onClick={() => handleReview("approved")} onClick={() => handleReview("approved")}
className="mt-4" className="mt-4"
@ -169,9 +176,9 @@ export const ReviewAccessRequestModal = ({
isChecked={byPassApproval} isChecked={byPassApproval}
id="byPassApproval" id="byPassApproval"
checkIndicatorBg="text-white" checkIndicatorBg="text-white"
className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""} className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""}
> >
<span className="text-red text-sm"> <span className="text-sm text-red">
Approve without waiting for requirements to be met (bypass policy protection) Approve without waiting for requirements to be met (bypass policy protection)
</span> </span>
</Checkbox> </Checkbox>

View File

@ -187,7 +187,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Environment</Th> <Th>Environment</Th>
<Th>Secret Path</Th> <Th>Secret Paths</Th>
<Th>Eligible Approvers</Th> <Th>Eligible Approvers</Th>
<Th>Eligible Group Approvers</Th> <Th>Eligible Group Approvers</Th>
<Th>Approval Required</Th> <Th>Approval Required</Th>

View File

@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; import { faCheckCircle, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@ -14,6 +14,8 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
FormControl, FormControl,
FormLabel,
IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
@ -22,7 +24,11 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { useCreateSecretApprovalPolicy, useListWorkspaceGroups, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import {
useCreateSecretApprovalPolicy,
useListWorkspaceGroups,
useUpdateSecretApprovalPolicy
} from "@app/hooks/api";
import { import {
useCreateAccessApprovalPolicy, useCreateAccessApprovalPolicy,
useUpdateAccessApprovalPolicy useUpdateAccessApprovalPolicy
@ -44,9 +50,13 @@ const formSchema = z
.object({ .object({
environment: z.string(), environment: z.string(),
name: z.string().optional(), name: z.string().optional(),
secretPath: z.string().optional(), secretPaths: z.object({ path: z.string() }).array().default([]),
approvals: z.number().min(1), approvals: z.number().min(1),
approvers: z.object({type: z.nativeEnum(ApproverType), id: z.string()}).array().min(1).default([]), approvers: z
.object({ type: z.nativeEnum(ApproverType), id: z.string() })
.array()
.min(1)
.default([]),
policyType: z.nativeEnum(PolicyType), policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel) enforcementLevel: z.nativeEnum(EnforcementLevel)
}) })
@ -70,12 +80,16 @@ export const AccessPolicyForm = ({
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
getValues,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: editValues values: editValues
? { ? {
...editValues, ...editValues,
secretPaths: (editValues.secretPaths ? editValues.secretPaths : []).map((path) => ({
path
})),
environment: editValues.environment.slug, environment: editValues.environment.slug,
approvers: editValues?.approvers || [], approvers: editValues?.approvers || [],
approvals: editValues?.approvals approvals: editValues?.approvals
@ -107,14 +121,23 @@ export const AccessPolicyForm = ({
if (data.policyType === PolicyType.ChangePolicy) { if (data.policyType === PolicyType.ChangePolicy) {
await createSecretApprovalPolicy({ await createSecretApprovalPolicy({
...data, ...data,
secretPaths: data.secretPaths.map((path) => path.path),
workspaceId: currentWorkspace?.id || "" workspaceId: currentWorkspace?.id || ""
}); });
} else { } else if (data.policyType === PolicyType.AccessPolicy) {
await createAccessApprovalPolicy({ await createAccessApprovalPolicy({
...data, ...data,
secretPaths: data.secretPaths.map((path) => path.path),
projectSlug projectSlug
}); });
} else {
createNotification({
type: "error",
text: "Invalid policy type"
});
return;
} }
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully created policy" text: "Successfully created policy"
@ -138,15 +161,24 @@ export const AccessPolicyForm = ({
await updateSecretApprovalPolicy({ await updateSecretApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...data,
secretPaths: data.secretPaths.map((path) => path.path),
workspaceId: currentWorkspace?.id || "" workspaceId: currentWorkspace?.id || ""
}); });
} else { } else if (data.policyType === PolicyType.AccessPolicy) {
await updateAccessApprovalPolicy({ await updateAccessApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...data,
secretPaths: data.secretPaths.map((path) => path.path),
projectSlug projectSlug
}); });
} else {
createNotification({
type: "error",
text: "Invalid policy type"
});
return;
} }
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully updated policy" text: "Successfully updated policy"
@ -169,11 +201,10 @@ export const AccessPolicyForm = ({
} }
}; };
const formatEnforcementLevel = (level: EnforcementLevel) => { const secretPathsFields = useFieldArray({
if (level === EnforcementLevel.Hard) return "Hard"; control,
if (level === EnforcementLevel.Soft) return "Soft"; name: "secretPaths"
return level; });
};
return ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
@ -252,19 +283,58 @@ export const AccessPolicyForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Controller <div>
control={control} <FormLabel
name="secretPath" tooltipText="Select which secret paths in th environment that this policy should apply for."
render={({ field, fieldState: { error } }) => ( label="Secret Paths"
<FormControl
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/> />
</div>
<div className="mb-3 mt-1 flex flex-col space-y-2">
{secretPathsFields.fields.map(({ id: secretPathId }, i) => (
<div key={secretPathId} className="flex items-end space-x-2">
<div className="flex-grow">
<Controller
control={control}
name={`secretPaths.${i}.path`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => {
const secretPaths = getValues("secretPaths");
if (secretPaths && secretPaths?.length > 0) {
secretPathsFields.remove(i);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => secretPathsFields.append({ path: "/" })}
>
Add Path
</Button>
</div>
</div>
<Controller <Controller
control={control} control={control}
name="approvals" name="approvals"
@ -307,12 +377,8 @@ export const AccessPolicyForm = ({
> >
{Object.values(EnforcementLevel).map((level) => { {Object.values(EnforcementLevel).map((level) => {
return ( return (
<SelectItem <SelectItem value={level} key={`enforcement-level-${level}`}>
value={level} <span className="capitalize">{level}</span>
key={`enforcement-level-${level}`}
className="text-xs"
>
{formatEnforcementLevel(level)}
</SelectItem> </SelectItem>
); );
})} })}
@ -334,7 +400,11 @@ export const AccessPolicyForm = ({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Input <Input
isReadOnly isReadOnly
value={value?.filter((e) => e.type=== ApproverType.User).length ? `${value.filter((e) => e.type=== ApproverType.User).length} selected` : "None"} value={
value?.filter((e) => e.type === ApproverType.User).length
? `${value.filter((e) => e.type === ApproverType.User).length} selected`
: "None"
}
className="text-left" className="text-left"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -347,15 +417,22 @@ export const AccessPolicyForm = ({
</DropdownMenuLabel> </DropdownMenuLabel>
{members.map(({ user }) => { {members.map(({ user }) => {
const { id: userId } = user; const { id: userId } = user;
const isChecked = value?.filter((el: {id: string, type: ApproverType}) => el.id === userId && el.type === ApproverType.User).length > 0; const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === userId && el.type === ApproverType.User
).length > 0;
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
evt.preventDefault(); evt.preventDefault();
onChange( onChange(
isChecked isChecked
? value?.filter((el: {id: string, type: ApproverType}) => el.id !== userId && el.type !== ApproverType.User) ? value?.filter(
: [...(value || []), {id:userId, type: ApproverType.User}] (el: { id: string; type: ApproverType }) =>
el.id !== userId && el.type !== ApproverType.User
)
: [...(value || []), { id: userId, type: ApproverType.User }]
); );
}} }}
key={`create-policy-members-${userId}`} key={`create-policy-members-${userId}`}
@ -384,7 +461,13 @@ export const AccessPolicyForm = ({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Input <Input
isReadOnly isReadOnly
value={value?.filter((e) => e.type=== ApproverType.Group).length ? `${value?.filter((e) => e.type=== ApproverType.Group).length} selected` : "None"} value={
value?.filter((e) => e.type === ApproverType.Group).length
? `${
value?.filter((e) => e.type === ApproverType.Group).length
} selected`
: "None"
}
className="text-left" className="text-left"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -395,28 +478,36 @@ export const AccessPolicyForm = ({
<DropdownMenuLabel> <DropdownMenuLabel>
Select groups that are allowed to approve requests Select groups that are allowed to approve requests
</DropdownMenuLabel> </DropdownMenuLabel>
{groups && groups.map(({ group }) => { {groups &&
const { id } = group; groups.map(({ group }) => {
const isChecked = value?.filter((el: {id: string, type: ApproverType}) => el.id === id && el.type === ApproverType.Group).length > 0; const { id } = group;
const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === id && el.type === ApproverType.Group
).length > 0;
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
evt.preventDefault(); evt.preventDefault();
onChange( onChange(
isChecked isChecked
? value?.filter((el: {id: string, type: ApproverType}) => el.id !== id && el.type !== ApproverType.Group) ? value?.filter(
: [...(value || []), {id, type: ApproverType.Group}] (el: { id: string; type: ApproverType }) =>
); el.id !== id && el.type !== ApproverType.Group
}} )
key={`create-policy-members-${id}`} : [...(value || []), { id, type: ApproverType.Group }]
iconPos="right" );
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />} }}
> key={`create-policy-members-${id}`}
{group.name} iconPos="right"
</DropdownMenuItem> icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
); >
})} {group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</FormControl> </FormControl>

View File

@ -29,13 +29,13 @@ interface IPolicy {
name: string; name: string;
environment: WorkspaceEnv; environment: WorkspaceEnv;
projectId?: string; projectId?: string;
secretPath?: string; secretPaths?: string[];
approvals: number; approvals: number;
approvers?: Approver[]; approvers?: Approver[];
updatedAt: Date; updatedAt: Date;
policyType: PolicyType; policyType: PolicyType;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
}; }
type Props = { type Props = {
policy: IPolicy; policy: IPolicy;
@ -56,10 +56,16 @@ export const ApprovalPolicyRow = ({
onEdit, onEdit,
onDelete onDelete
}: Props) => { }: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []); const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(
const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); );
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(
policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []
);
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } =
useUpdateAccessApprovalPolicy();
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } =
useUpdateSecretApprovalPolicy();
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
@ -68,7 +74,7 @@ export const ApprovalPolicyRow = ({
<Tr> <Tr>
<Td>{policy.name}</Td> <Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPaths?.length ? policy?.secretPaths.join(", ") : "Full Environment"}</Td>
<Td> <Td>
<DropdownMenu <DropdownMenu
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
@ -78,11 +84,15 @@ export const ApprovalPolicyRow = ({
{ {
projectSlug, projectSlug,
id: policy.id, id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers), approvers: selectedApprovers.concat(selectedGroupApprovers)
}, },
{ {
onError: () => { onError: () => {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); setSelectedApprovers(
policy?.approvers?.filter(
(approver) => approver.type === ApproverType.User
) || []
);
} }
} }
); );
@ -91,17 +101,23 @@ export const ApprovalPolicyRow = ({
{ {
workspaceId, workspaceId,
id: policy.id, id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers), approvers: selectedApprovers.concat(selectedGroupApprovers)
}, },
{ {
onError: () => { onError: () => {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); setSelectedApprovers(
policy?.approvers?.filter(
(approver) => approver.type === ApproverType.User
) || []
);
} }
} }
); );
} }
} else { } else {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []); setSelectedApprovers(
policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []
);
} }
}} }}
> >
@ -127,13 +143,19 @@ export const ApprovalPolicyRow = ({
</DropdownMenuLabel> </DropdownMenuLabel>
{members?.map(({ user }) => { {members?.map(({ user }) => {
const userId = user.id; const userId = user.id;
const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0; const isChecked =
selectedApprovers?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === userId && el.type === ApproverType.User
).length > 0;
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
evt.preventDefault(); evt.preventDefault();
setSelectedApprovers((state) => setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }] isChecked
? state.filter((el) => el.id !== userId || el.type !== ApproverType.User)
: [...state, { id: userId, type: ApproverType.User }]
); );
}} }}
key={`create-policy-members-${userId}`} key={`create-policy-members-${userId}`}
@ -156,37 +178,51 @@ export const ApprovalPolicyRow = ({
{ {
projectSlug, projectSlug,
id: policy.id, id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers), approvers: selectedApprovers.concat(selectedGroupApprovers)
}, },
{ {
onError: () => { onError: () => {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); setSelectedGroupApprovers(
policy?.approvers?.filter(
(approver) => approver.type === ApproverType.Group
) || []
);
} }
}, }
); );
} else { } else {
updateSecretApprovalPolicy( updateSecretApprovalPolicy(
{ {
workspaceId, workspaceId,
id: policy.id, id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers), approvers: selectedApprovers.concat(selectedGroupApprovers)
}, },
{ {
onError: () => { onError: () => {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); setSelectedGroupApprovers(
policy?.approvers?.filter(
(approver) => approver.type === ApproverType.Group
) || []
);
} }
} }
); );
} }
} else { } else {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); setSelectedGroupApprovers(
policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []
);
} }
}} }}
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Input <Input
isReadOnly isReadOnly
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"} value={
selectedGroupApprovers?.length
? `${selectedGroupApprovers.length} selected`
: "None"
}
className="text-left" className="text-left"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -197,27 +233,34 @@ export const ApprovalPolicyRow = ({
<DropdownMenuLabel> <DropdownMenuLabel>
Select groups that are allowed to approve requests Select groups that are allowed to approve requests
</DropdownMenuLabel> </DropdownMenuLabel>
{groups && groups.map(({ group }) => { {groups &&
const { id } = group; groups.map(({ group }) => {
const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0; const { id } = group;
return ( const isChecked =
<DropdownMenuItem selectedGroupApprovers?.filter(
onClick={(evt) => { (el: { id: string; type: ApproverType }) =>
evt.preventDefault(); el.id === id && el.type === ApproverType.Group
setSelectedGroupApprovers( ).length > 0;
isChecked return (
? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group) <DropdownMenuItem
: [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }] onClick={(evt) => {
); evt.preventDefault();
}} setSelectedGroupApprovers(
key={`create-policy-groups-${id}`} isChecked
iconPos="right" ? selectedGroupApprovers?.filter(
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />} (el) => el.id !== id || el.type !== ApproverType.Group
> )
{group.name} : [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }]
</DropdownMenuItem> );
); }}
})} key={`create-policy-groups-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</Td> </Td>
@ -229,12 +272,12 @@ export const ApprovalPolicyRow = ({
</Td> </Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer"> <DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex justify-center items-center hover:text-primary-400 data-[state=open]:text-primary-400 hover:scale-125 data-[state=open]:scale-125 transition-transform duration-300 ease-in-out"> <div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} /> <FontAwesomeIcon size="sm" icon={faEllipsis} />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="p-1 min-w-[100%]"> <DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}