mirror of
https://github.com/Infisical/infisical.git
synced 2025-07-11 12:11:38 +00:00
Compare commits
5 Commits
create-pol
...
daniel/app
Author | SHA1 | Date | |
---|---|---|---|
31381b9b4b | |||
9736bc517d | |||
0aaad1eeb8 | |||
8b2adbbe95 | |||
a96cbe6252 |
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({
|
||||
envId: z.string().uuid(),
|
||||
createdAt: 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>;
|
||||
|
@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
|
||||
export const IdentityMetadataSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
value: z.string().nullable().optional(),
|
||||
orgId: z.string().uuid(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
identityId: z.string().uuid().nullable().optional(),
|
||||
|
@ -12,7 +12,7 @@ import { TImmutableDBKeys } from "./models";
|
||||
export const KmsRootConfigSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedRootKey: zodBuffer,
|
||||
encryptionStrategy: z.string(),
|
||||
encryptionStrategy: z.string().default("SOFTWARE").nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
@ -20,7 +20,8 @@ export const ProjectUserAdditionalPrivilegeSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
userId: z.string().uuid(),
|
||||
projectId: z.string()
|
||||
projectId: z.string(),
|
||||
accessRequestId: z.string().uuid().nullable().optional()
|
||||
});
|
||||
|
||||
export type TProjectUserAdditionalPrivilege = z.infer<typeof ProjectUserAdditionalPrivilegeSchema>;
|
||||
|
@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({
|
||||
envId: z.string().uuid(),
|
||||
createdAt: 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>;
|
||||
|
@ -2,6 +2,7 @@ import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
|
||||
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 { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
@ -19,7 +20,10 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
body: z.object({
|
||||
projectSlug: z.string().trim(),
|
||||
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(),
|
||||
approvers: z
|
||||
.discriminatedUnion("type", [
|
||||
@ -49,6 +53,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`,
|
||||
enforcementLevel: req.body.enforcementLevel
|
||||
});
|
||||
|
||||
return { approval };
|
||||
}
|
||||
});
|
||||
@ -134,11 +139,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().optional(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? "/" : val)),
|
||||
secretPaths: z.string().array().optional(),
|
||||
approvers: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(ApproverType.Group), id: z.string() }),
|
||||
|
@ -20,7 +20,14 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
method: "POST",
|
||||
schema: {
|
||||
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(),
|
||||
temporaryRange: z.string().optional()
|
||||
}),
|
||||
@ -39,7 +46,9 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
permissions: req.body.permissions,
|
||||
environment: req.body.environment,
|
||||
secretPaths: req.body.secretPaths,
|
||||
requestedActions: req.body.requestedActions,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectSlug: req.query.projectSlug,
|
||||
temporaryRange: req.body.temporaryRange,
|
||||
@ -107,7 +116,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
name: z.string(),
|
||||
approvals: z.number(),
|
||||
approvers: z.string().array(),
|
||||
secretPath: z.string().nullish(),
|
||||
secretPaths: z.string().array(),
|
||||
envId: z.string(),
|
||||
enforcementLevel: z.string()
|
||||
}),
|
||||
|
@ -2,7 +2,7 @@ import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
|
||||
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 { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
@ -21,12 +21,10 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
workspaceId: z.string(),
|
||||
name: z.string().optional(),
|
||||
environment: z.string(),
|
||||
secretPath: z
|
||||
secretPaths: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.default("/")
|
||||
.transform((val) => (val ? removeTrailingSlash(val) : val)),
|
||||
.array()
|
||||
.transform((val) => val.map((v) => prefixWithSlash(removeTrailingSlash(v)).trim())),
|
||||
approvers: z
|
||||
.discriminatedUnion("type", [
|
||||
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)}`,
|
||||
enforcementLevel: req.body.enforcementLevel
|
||||
});
|
||||
|
||||
return { approval };
|
||||
}
|
||||
});
|
||||
@ -79,12 +78,12 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
.array()
|
||||
.min(1, { message: "At least one approver should be provided" }),
|
||||
approvals: z.number().min(1).default(1),
|
||||
secretPath: z
|
||||
secretPaths: z
|
||||
.string()
|
||||
.array()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((val) => (val ? removeTrailingSlash(val) : val))
|
||||
.transform((val) => (val === "" ? "/" : val)),
|
||||
.transform((val) => (val ? val.map((v) => prefixWithSlash(removeTrailingSlash(v)).trim()) : val)),
|
||||
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel).optional()
|
||||
}),
|
||||
response: {
|
||||
|
@ -41,8 +41,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
response: {
|
||||
200: z.object({
|
||||
approvals: SecretApprovalRequestsSchema.extend({
|
||||
// secretPath: z.string(),
|
||||
policy: z.object({
|
||||
secretPaths: z.string().array(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
approvals: z.number(),
|
||||
@ -51,7 +51,6 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
userId: z.string().nullable().optional()
|
||||
})
|
||||
.array(),
|
||||
secretPath: z.string().optional().nullable(),
|
||||
enforcementLevel: z.string()
|
||||
}),
|
||||
committerUser: approvalRequestUser,
|
||||
@ -253,13 +252,12 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
200: z.object({
|
||||
approval: SecretApprovalRequestsSchema.merge(
|
||||
z.object({
|
||||
// secretPath: z.string(),
|
||||
policy: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
approvals: z.number(),
|
||||
approvers: approvalRequestUser.array(),
|
||||
secretPath: z.string().optional().nullable(),
|
||||
secretPaths: z.string().array(),
|
||||
enforcementLevel: z.string()
|
||||
}),
|
||||
environment: z.string(),
|
||||
@ -308,6 +306,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
return { approval };
|
||||
}
|
||||
});
|
||||
|
@ -17,15 +17,21 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||
filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>,
|
||||
customFilter?: {
|
||||
policyId?: string;
|
||||
secretPaths?: string[];
|
||||
}
|
||||
) => {
|
||||
const result = await tx(TableName.AccessApprovalPolicy)
|
||||
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(
|
||||
@ -43,6 +49,51 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||
.select(tx.ref("projectId").withSchema(TableName.Environment))
|
||||
.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;
|
||||
};
|
||||
|
||||
@ -109,8 +160,8 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||
slug: data.envSlug
|
||||
},
|
||||
projectId: data.projectId,
|
||||
...AccessApprovalPoliciesSchema.parse(data)
|
||||
// secretPath: data.secretPath || undefined,
|
||||
...AccessApprovalPoliciesSchema.parse(data),
|
||||
secretPaths: data.secretPaths as string[]
|
||||
}),
|
||||
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 };
|
||||
};
|
||||
|
@ -48,7 +48,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
actorAuthMethod,
|
||||
approvals,
|
||||
approvers,
|
||||
@ -138,7 +138,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
{
|
||||
envId: env.id,
|
||||
approvals,
|
||||
secretPath,
|
||||
secretPaths: JSON.stringify(secretPaths),
|
||||
name,
|
||||
enforcementLevel
|
||||
},
|
||||
@ -166,7 +166,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
|
||||
return doc;
|
||||
});
|
||||
return { ...accessApproval, environment: env, projectId: project.id };
|
||||
return { ...accessApproval, environment: env, projectId: project.id, secretPaths };
|
||||
};
|
||||
|
||||
const getAccessApprovalPolicyByProjectSlug = async ({
|
||||
@ -190,13 +190,14 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
// ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||
|
||||
const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id });
|
||||
|
||||
return accessApprovalPolicies;
|
||||
};
|
||||
|
||||
const updateAccessApprovalPolicy = async ({
|
||||
policyId,
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
actorId,
|
||||
actor,
|
||||
@ -246,7 +247,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
accessApprovalPolicy.id,
|
||||
{
|
||||
approvals,
|
||||
secretPath,
|
||||
secretPaths: JSON.stringify(secretPaths),
|
||||
name,
|
||||
enforcementLevel
|
||||
},
|
||||
@ -321,13 +322,14 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
await accessApprovalPolicyDAL.deleteById(policyId);
|
||||
return policy;
|
||||
return { ...policy, secretPaths: policy.secretPaths as string[] };
|
||||
};
|
||||
|
||||
const getAccessPolicyCountByEnvSlug = async ({
|
||||
|
@ -20,7 +20,7 @@ export enum ApproverType {
|
||||
|
||||
export type TCreateAccessApprovalPolicy = {
|
||||
approvals: number;
|
||||
secretPath: string;
|
||||
secretPaths: string[];
|
||||
environment: string;
|
||||
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
|
||||
projectSlug: string;
|
||||
@ -32,7 +32,7 @@ export type TUpdateAccessApprovalPolicy = {
|
||||
policyId: string;
|
||||
approvals?: number;
|
||||
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
|
||||
secretPath?: string;
|
||||
secretPaths?: string[];
|
||||
name?: string;
|
||||
enforcementLevel?: EnforcementLevel;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
@ -59,7 +59,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"),
|
||||
db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
|
||||
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("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId")
|
||||
)
|
||||
@ -78,7 +78,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")
|
||||
)
|
||||
|
||||
// TODO: ADD SUPPORT FOR GROUPS!!!!
|
||||
.select(
|
||||
db.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"),
|
||||
db.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"),
|
||||
@ -116,9 +115,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
id: doc.policyId,
|
||||
name: doc.policyName,
|
||||
approvals: doc.policyApprovals,
|
||||
secretPath: doc.policySecretPath,
|
||||
enforcementLevel: doc.policyEnforcementLevel,
|
||||
envId: doc.policyEnvId
|
||||
envId: doc.policyEnvId,
|
||||
secretPaths: doc.policySecretPaths as string[]
|
||||
},
|
||||
requestedByUser: {
|
||||
userId: doc.requestedByUserId,
|
||||
@ -250,7 +249,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
|
||||
tx.ref("projectId").withSchema(TableName.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("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals")
|
||||
);
|
||||
@ -270,8 +269,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
id: el.policyId,
|
||||
name: el.policyName,
|
||||
approvals: el.policyApprovals,
|
||||
secretPath: el.policySecretPath,
|
||||
enforcementLevel: el.policyEnforcementLevel
|
||||
enforcementLevel: el.policyEnforcementLevel,
|
||||
secretPaths: el.policySecretPaths as string[]
|
||||
},
|
||||
requestedByUser: {
|
||||
userId: el.requestedByUserId,
|
||||
|
@ -4,11 +4,7 @@ import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TVerifyPermission } from "./access-approval-request-types";
|
||||
|
||||
function filterUnique(value: string, index: number, array: string[]) {
|
||||
return array.indexOf(value) === index;
|
||||
}
|
||||
|
||||
export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => {
|
||||
export const verifyRequestedPermissions = ({ permissions, checkPath }: TVerifyPermission) => {
|
||||
const permission = unpackRules(
|
||||
permissions as PackRule<{
|
||||
// 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" });
|
||||
}
|
||||
|
||||
const requestedPermissions: string[] = [];
|
||||
|
||||
for (const p of permission) {
|
||||
if (p.action[0] === "read") requestedPermissions.push("Read Access");
|
||||
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");
|
||||
}
|
||||
|
||||
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;
|
||||
for (const perm of permission) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment
|
||||
const permissionEnv = firstPermission.conditions?.environment;
|
||||
const permissionEnv = perm.conditions?.environment;
|
||||
|
||||
if (!permissionEnv || typeof permissionEnv !== "string") {
|
||||
throw new BadRequestError({ message: "Permission environment is not a string" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
return {
|
||||
envSlug: permissionEnv,
|
||||
secretPath: permissionSecretPath,
|
||||
accessTypes: requestedPermissions.filter(filterUnique)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
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 { TGroupDALFactory } from "../group/group-dal";
|
||||
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 { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
|
||||
import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal";
|
||||
@ -89,7 +91,9 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
isTemporary,
|
||||
temporaryRange,
|
||||
actorId,
|
||||
permissions: requestedPermissions,
|
||||
environment: envSlug,
|
||||
secretPaths,
|
||||
requestedActions,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
@ -116,18 +120,75 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
|
||||
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 });
|
||||
|
||||
if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` });
|
||||
|
||||
const policy = await accessApprovalPolicyDAL.findOne({
|
||||
envId: environment.id,
|
||||
secretPath
|
||||
});
|
||||
const policy = await accessApprovalPolicyDAL.findOne(
|
||||
{
|
||||
envId: environment.id
|
||||
},
|
||||
{
|
||||
secretPaths: secretPaths?.length ? secretPaths : []
|
||||
}
|
||||
);
|
||||
|
||||
if (!policy) {
|
||||
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,
|
||||
isTemporary,
|
||||
requesterEmail: requestedByUser.email as string,
|
||||
secretPath,
|
||||
secretPath: secretPaths?.join(", ") || "",
|
||||
environment: envSlug,
|
||||
permissions: accessTypes,
|
||||
permissions: enabledActions,
|
||||
approvalUrl
|
||||
}
|
||||
}
|
||||
@ -244,9 +305,9 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
...(isTemporary && {
|
||||
expiresIn: ms(ms(temporaryRange || ""), { long: true })
|
||||
}),
|
||||
secretPath,
|
||||
secretPath: secretPaths?.join(", ") || "",
|
||||
environment: envSlug,
|
||||
permissions: accessTypes,
|
||||
permissions: enabledActions,
|
||||
approvalUrl
|
||||
},
|
||||
template: SmtpTemplates.AccessApprovalRequest
|
||||
|
@ -8,6 +8,7 @@ export enum ApprovalStatus {
|
||||
|
||||
export type TVerifyPermission = {
|
||||
permissions: unknown;
|
||||
checkPath?: boolean;
|
||||
};
|
||||
|
||||
export type TGetAccessRequestCountDTO = {
|
||||
@ -21,7 +22,15 @@ export type TReviewAccessRequestDTO = {
|
||||
|
||||
export type TCreateAccessApprovalRequestDTO = {
|
||||
projectSlug: string;
|
||||
permissions: unknown;
|
||||
environment: string;
|
||||
// permissions: unknown;
|
||||
requestedActions: {
|
||||
read: boolean;
|
||||
edit: boolean;
|
||||
create: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
secretPaths: string[];
|
||||
isTemporary: boolean;
|
||||
temporaryRange?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
@ -232,7 +232,7 @@ export const permissionServiceFactory = ({
|
||||
objectify(
|
||||
userProjectPermission.metadata,
|
||||
(i) => i.key,
|
||||
(i) => i.value
|
||||
(i) => i.value || ""
|
||||
)
|
||||
);
|
||||
const interpolateRules = templatedRules(
|
||||
@ -299,7 +299,7 @@ export const permissionServiceFactory = ({
|
||||
objectify(
|
||||
identityProjectPermission.metadata,
|
||||
(i) => i.key,
|
||||
(i) => i.value
|
||||
(i) => i.value || ""
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -135,7 +135,8 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||
parentMapper: (data) => ({
|
||||
environment: { id: data.envId, name: data.envName, slug: data.envSlug },
|
||||
projectId: data.projectId,
|
||||
...SecretApprovalPoliciesSchema.parse(data)
|
||||
...SecretApprovalPoliciesSchema.parse(data),
|
||||
secretPaths: data.secretPaths as string[]
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
|
@ -22,10 +22,22 @@ import {
|
||||
TUpdateSapDTO
|
||||
} 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
|
||||
// eslint-disable-next-line
|
||||
policy.secretPath ? (containsGlobPatterns(policy.secretPath) ? 1 : 2) : 0;
|
||||
/*
|
||||
* '1': The secret path is a glob pattern
|
||||
* '0': The secret path is not defined (whole environment is scoped)
|
||||
* '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 = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
@ -55,7 +67,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
approvals,
|
||||
approvers,
|
||||
projectId,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
environment,
|
||||
enforcementLevel
|
||||
}: TCreateSapDTO) => {
|
||||
@ -105,7 +117,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
{
|
||||
envId: env.id,
|
||||
approvals,
|
||||
secretPath,
|
||||
secretPaths: JSON.stringify(secretPaths),
|
||||
name,
|
||||
enforcementLevel
|
||||
},
|
||||
@ -153,12 +165,12 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
return doc;
|
||||
});
|
||||
|
||||
return { ...secretApproval, environment: env, projectId };
|
||||
return { ...secretApproval, environment: env, projectId, secretPaths };
|
||||
};
|
||||
|
||||
const updateSecretApprovalPolicy = async ({
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
actorId,
|
||||
actor,
|
||||
@ -209,7 +221,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
secretApprovalPolicy.id,
|
||||
{
|
||||
approvals,
|
||||
secretPath,
|
||||
secretPaths: secretPaths ? JSON.stringify(secretPaths) : undefined,
|
||||
name,
|
||||
enforcementLevel
|
||||
},
|
||||
@ -261,7 +273,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
return doc;
|
||||
return { ...doc, secretPaths: doc.secretPaths as string[] };
|
||||
});
|
||||
return {
|
||||
...updatedSap,
|
||||
@ -302,7 +314,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
}
|
||||
|
||||
await secretApprovalPolicyDAL.deleteById(secretPolicyId);
|
||||
return sapPolicy;
|
||||
return { ...sapPolicy, secretPaths: sapPolicy.secretPaths as string[] };
|
||||
};
|
||||
|
||||
const getSecretApprovalPolicyByProjectId = async ({
|
||||
@ -325,8 +337,9 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
return sapPolicies;
|
||||
};
|
||||
|
||||
const getSecretApprovalPolicy = async (projectId: string, environment: string, path: string) => {
|
||||
const secretPath = removeTrailingSlash(path);
|
||||
const getSecretApprovalPolicy = async (projectId: string, environment: string, paths: string[] | string) => {
|
||||
const secretPaths = (Array.isArray(paths) ? paths : [paths]).map((p) => removeTrailingSlash(p).trim());
|
||||
|
||||
const env = await projectEnvDAL.findOne({ slug: environment, projectId });
|
||||
if (!env) {
|
||||
throw new NotFoundError({
|
||||
@ -336,14 +349,24 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
|
||||
const policies = await secretApprovalPolicyDAL.find({ envId: env.id });
|
||||
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(
|
||||
({ secretPath: policyPath }) => !policyPath || picomatch.isMatch(secretPath, policyPath, { strictSlashes: false })
|
||||
|
||||
// A policy matches if either:
|
||||
// 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
|
||||
// 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();
|
||||
|
||||
return finalPolicy;
|
||||
};
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { ApproverType } from "../access-approval-policy/access-approval-policy-t
|
||||
|
||||
export type TCreateSapDTO = {
|
||||
approvals: number;
|
||||
secretPath?: string | null;
|
||||
secretPaths: string[];
|
||||
environment: string;
|
||||
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
|
||||
projectId: string;
|
||||
@ -15,7 +15,7 @@ export type TCreateSapDTO = {
|
||||
export type TUpdateSapDTO = {
|
||||
secretPolicyId: string;
|
||||
approvals?: number;
|
||||
secretPath?: string | null;
|
||||
secretPaths?: string[];
|
||||
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
|
||||
name?: string;
|
||||
enforcementLevel?: EnforcementLevel;
|
||||
|
@ -108,7 +108,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
|
||||
tx.ref("projectId").withSchema(TableName.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("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
|
||||
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")
|
||||
@ -145,7 +145,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
id: el.policyId,
|
||||
name: el.policyName,
|
||||
approvals: el.policyApprovals,
|
||||
secretPath: el.policySecretPath,
|
||||
secretPaths: el.policySecretPaths as string[],
|
||||
enforcementLevel: el.policyEnforcementLevel,
|
||||
envId: el.policyEnvId
|
||||
}
|
||||
@ -323,7 +323,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
db.raw(
|
||||
`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("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
|
||||
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
|
||||
@ -352,7 +352,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
id: el.policyId,
|
||||
name: el.policyName,
|
||||
approvals: el.policyApprovals,
|
||||
secretPath: el.policySecretPath,
|
||||
secretPaths: el.policySecretPaths as string[],
|
||||
enforcementLevel: el.policyEnforcementLevel
|
||||
},
|
||||
committerUser: {
|
||||
@ -470,7 +470,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
db.raw(
|
||||
`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("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
|
||||
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
|
||||
@ -499,7 +499,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
id: el.policyId,
|
||||
name: el.policyName,
|
||||
approvals: el.policyApprovals,
|
||||
secretPath: el.policySecretPath,
|
||||
secretPaths: el.policySecretPaths as string[],
|
||||
enforcementLevel: el.policyEnforcementLevel
|
||||
},
|
||||
committerUser: {
|
||||
|
@ -294,10 +294,10 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
: undefined
|
||||
}));
|
||||
}
|
||||
const secretPath = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [
|
||||
const [secretPath] = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [
|
||||
secretApprovalRequest.folderId
|
||||
]);
|
||||
return { ...secretApprovalRequest, secretPath: secretPath?.[0]?.path || "/", commits: secrets };
|
||||
return { ...secretApprovalRequest, secretPath: secretPath?.path || "/", commits: secrets };
|
||||
};
|
||||
|
||||
const reviewApproval = async ({
|
||||
@ -831,7 +831,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`,
|
||||
requesterEmail: requestedByUser.email,
|
||||
bypassReason,
|
||||
secretPath: policy.secretPath,
|
||||
secretPath: folder.path,
|
||||
environment: env.name,
|
||||
approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval`
|
||||
},
|
||||
|
@ -56,16 +56,15 @@ export const DefaultResponseErrorsSchema = {
|
||||
})
|
||||
};
|
||||
|
||||
export const sapPubSchema = SecretApprovalPoliciesSchema.merge(
|
||||
z.object({
|
||||
export const sapPubSchema = SecretApprovalPoliciesSchema.extend({
|
||||
secretPaths: z.string().array(),
|
||||
environment: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string()
|
||||
}),
|
||||
projectId: z.string()
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export const sanitizedServiceTokenUserSchema = UsersSchema.pick({
|
||||
authMethods: true,
|
||||
|
@ -207,7 +207,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
||||
.object({
|
||||
key: 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()
|
||||
.optional(),
|
||||
|
@ -135,7 +135,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
.object({
|
||||
key: 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()
|
||||
.optional(),
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
@ -22,7 +21,7 @@ export const useCreateAccessApprovalPolicy = () => {
|
||||
approvals,
|
||||
approvers,
|
||||
name,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
enforcementLevel
|
||||
}) => {
|
||||
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
|
||||
@ -30,7 +29,7 @@ export const useCreateAccessApprovalPolicy = () => {
|
||||
projectSlug,
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
enforcementLevel
|
||||
});
|
||||
@ -46,11 +45,11 @@ export const useUpdateAccessApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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}`, {
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
enforcementLevel
|
||||
});
|
||||
@ -83,8 +82,8 @@ export const useCreateAccessRequest = () => {
|
||||
const { data } = await apiRequest.post<TAccessApproval>(
|
||||
"/api/v1/access-approvals/requests",
|
||||
{
|
||||
...request,
|
||||
permissions: request.permissions ? packRules(request.permissions) : undefined
|
||||
...request
|
||||
// permissions: request.permissions ? packRules(request.permissions) : undefined
|
||||
},
|
||||
{
|
||||
params: {
|
||||
|
@ -73,7 +73,7 @@ const fetchApprovalRequests = async ({
|
||||
{ params: { projectSlug, envSlug, authorProjectMembershipId } }
|
||||
);
|
||||
|
||||
return data.requests.map((request) => ({
|
||||
const result = data.requests.map((request) => ({
|
||||
...request,
|
||||
|
||||
privilege: request.privilege
|
||||
@ -86,6 +86,8 @@ const fetchApprovalRequests = async ({
|
||||
: null,
|
||||
permissions: unpackRules(request.permissions as unknown as PackRule<TProjectPermission>[])
|
||||
}));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const fetchAccessRequestsCount = async (projectSlug: string) => {
|
||||
|
@ -6,7 +6,7 @@ export type TAccessApprovalPolicy = {
|
||||
id: string;
|
||||
name: string;
|
||||
approvals: number;
|
||||
secretPath: string;
|
||||
secretPaths: string[];
|
||||
envId: string;
|
||||
workspace: string;
|
||||
environment: WorkspaceEnv;
|
||||
@ -26,7 +26,7 @@ export enum ApproverType{
|
||||
export type Approver = {
|
||||
id: string;
|
||||
type: ApproverType;
|
||||
}
|
||||
};
|
||||
|
||||
export type TAccessApprovalRequest = {
|
||||
id: string;
|
||||
@ -67,7 +67,7 @@ export type TAccessApprovalRequest = {
|
||||
name: string;
|
||||
approvals: number;
|
||||
approvers: string[];
|
||||
secretPath?: string | null;
|
||||
secretPaths?: string[] | null;
|
||||
envId: string;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
};
|
||||
@ -116,7 +116,18 @@ export type TProjectUserPrivilege = {
|
||||
|
||||
export type TCreateAccessRequestDTO = {
|
||||
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 = {
|
||||
projectSlug: string;
|
||||
@ -132,7 +143,7 @@ export type TGetAccessPolicyApprovalCountDTO = {
|
||||
export type TGetSecretApprovalPolicyOfBoardDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretPaths: string[];
|
||||
};
|
||||
|
||||
export type TCreateAccessPolicyDTO = {
|
||||
@ -141,7 +152,7 @@ export type TCreateAccessPolicyDTO = {
|
||||
environment: string;
|
||||
approvers?: Approver[];
|
||||
approvals?: number;
|
||||
secretPath?: string;
|
||||
secretPaths?: string[];
|
||||
enforcementLevel?: EnforcementLevel;
|
||||
};
|
||||
|
||||
@ -149,7 +160,7 @@ export type TUpdateAccessPolicyDTO = {
|
||||
id: string;
|
||||
name?: string;
|
||||
approvers?: Approver[];
|
||||
secretPath?: string;
|
||||
secretPaths?: string[];
|
||||
environment?: string;
|
||||
approvals?: number;
|
||||
enforcementLevel?: EnforcementLevel;
|
||||
|
@ -37,7 +37,7 @@ export type IdentityMembershipOrg = {
|
||||
id: string;
|
||||
identity: Identity;
|
||||
organization: string;
|
||||
metadata: { key: string; value: string; id: string }[];
|
||||
metadata: { key: string; value?: string | null; id: string }[];
|
||||
role: "admin" | "member" | "viewer" | "no-access" | "custom";
|
||||
customRole?: TOrgRole;
|
||||
createdAt: string;
|
||||
|
@ -14,7 +14,7 @@ export const useCreateSecretApprovalPolicy = () => {
|
||||
workspaceId,
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
enforcementLevel
|
||||
}) => {
|
||||
@ -23,7 +23,7 @@ export const useCreateSecretApprovalPolicy = () => {
|
||||
workspaceId,
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
enforcementLevel
|
||||
});
|
||||
@ -39,11 +39,11 @@ export const useUpdateSecretApprovalPolicy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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}`, {
|
||||
approvals,
|
||||
approvers,
|
||||
secretPath,
|
||||
secretPaths,
|
||||
name,
|
||||
enforcementLevel
|
||||
});
|
||||
|
@ -7,7 +7,7 @@ export type TSecretApprovalPolicy = {
|
||||
name: string;
|
||||
envId: string;
|
||||
environment: WorkspaceEnv;
|
||||
secretPath?: string;
|
||||
secretPaths: string[];
|
||||
approvals: number;
|
||||
approvers: Approver[];
|
||||
updatedAt: Date;
|
||||
@ -22,7 +22,7 @@ export enum ApproverType{
|
||||
export type Approver = {
|
||||
id: string;
|
||||
type: ApproverType;
|
||||
}
|
||||
};
|
||||
|
||||
export type TGetSecretApprovalPoliciesDTO = {
|
||||
workspaceId: string;
|
||||
@ -38,7 +38,7 @@ export type TCreateSecretPolicyDTO = {
|
||||
workspaceId: string;
|
||||
name?: string;
|
||||
environment: string;
|
||||
secretPath?: string | null;
|
||||
secretPaths: string[];
|
||||
approvers?: Approver[];
|
||||
approvals?: number;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
@ -48,7 +48,7 @@ export type TUpdateSecretPolicyDTO = {
|
||||
id: string;
|
||||
name?: string;
|
||||
approvers?: Approver[];
|
||||
secretPath?: string | null;
|
||||
secretPaths?: string[];
|
||||
approvals?: number;
|
||||
enforcementLevel?: EnforcementLevel;
|
||||
// for invalidating list
|
||||
|
@ -51,7 +51,7 @@ export type UserEnc = {
|
||||
|
||||
export type OrgUser = {
|
||||
id: string;
|
||||
metadata: { key: string; value: string; id: string }[];
|
||||
metadata: { key: string; value?: string | null; id: string }[];
|
||||
user: {
|
||||
username: string;
|
||||
email?: string;
|
||||
|
@ -1,9 +1,15 @@
|
||||
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
export const formatReservedPaths = (secretPath: string) => {
|
||||
const i = secretPath.indexOf(ReservedFolders.SecretReplication);
|
||||
export const formatReservedPaths = (paths: string | string[]) => {
|
||||
const secretPaths = Array.isArray(paths) ? paths : [paths];
|
||||
|
||||
const formattedSecretPaths = secretPaths.map((secretPath) => {
|
||||
const i = secretPath?.indexOf(ReservedFolders.SecretReplication);
|
||||
if (i !== -1) {
|
||||
return `${secretPath.slice(0, i)} - (replication)`;
|
||||
}
|
||||
return secretPath;
|
||||
});
|
||||
|
||||
return formattedSecretPaths.join(", ");
|
||||
};
|
||||
|
@ -51,7 +51,7 @@ import {
|
||||
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
secretPaths: z.string().optional(),
|
||||
environmentSlug: z.string(),
|
||||
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||
@ -99,7 +99,7 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
? {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// 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)
|
||||
: "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
@ -132,7 +132,7 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const selectedEnvironment = privilegeForm.watch("environmentSlug");
|
||||
const secretPath = privilegeForm.watch("secretPath");
|
||||
const secretPath = privilegeForm.watch("secretPaths");
|
||||
|
||||
const readAccess = privilegeForm.watch("read");
|
||||
const createAccess = privilegeForm.watch("create");
|
||||
@ -147,11 +147,11 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
(policy) => policy.environment.slug === selectedEnvironment
|
||||
);
|
||||
|
||||
privilegeForm.setValue("secretPath", "", {
|
||||
privilegeForm.setValue("secretPaths", "", {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
return [...environmentPolicies.map((policy) => policy.secretPath)];
|
||||
return [...environmentPolicies.map((policy) => policy.secretPaths)];
|
||||
}, [policies, selectedEnvironment]);
|
||||
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
@ -199,7 +199,7 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.secretPath) {
|
||||
if (!data.secretPaths) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please select a secret path",
|
||||
@ -208,30 +208,21 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
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({
|
||||
...data,
|
||||
...(data.temporaryAccess.isTemporary && {
|
||||
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,
|
||||
isTemporary: data.temporaryAccess.isTemporary,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
}))
|
||||
isTemporary: data.temporaryAccess.isTemporary
|
||||
});
|
||||
|
||||
createNotification({
|
||||
@ -285,7 +276,7 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
name="secretPaths"
|
||||
render={({ field }) => {
|
||||
if (policies) {
|
||||
return (
|
||||
@ -294,18 +285,28 @@ export const SpecificPrivilegeSecretForm = ({
|
||||
content="The selected environment doesn't have any policies."
|
||||
>
|
||||
<div>
|
||||
<FormControl label="Secret Path">
|
||||
<FormControl label="Secret Paths">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled || !selectablePaths.length}
|
||||
className="w-48"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{selectablePaths.map((path) => (
|
||||
<SelectItem value={path} key={path}>
|
||||
{path}
|
||||
{selectablePaths.map((paths) => {
|
||||
if (!paths || paths.length === 0) {
|
||||
return (
|
||||
<SelectItem value={JSON.stringify([])} key="empty">
|
||||
Full Environment Access
|
||||
</SelectItem>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectItem value={JSON.stringify(paths)} key={paths.join("-")}>
|
||||
{paths.join(", ")}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
@ -56,7 +56,9 @@ const generateRequestText = (request: TAccessApprovalRequest, userId: string) =>
|
||||
<div>
|
||||
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">
|
||||
{request.policy.secretPath}
|
||||
{request.policy.secretPaths?.length
|
||||
? request.policy.secretPaths.join(", ")
|
||||
: "Full environment"}
|
||||
</code>
|
||||
in
|
||||
<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
|
||||
});
|
||||
|
||||
const { data: requests } = useGetAccessApprovalRequests({
|
||||
const { data: requests, isLoading: isRequestsLoading } = useGetAccessApprovalRequests({
|
||||
projectSlug,
|
||||
authorProjectMembershipId: requestedByFilter,
|
||||
envSlug: envFilter
|
||||
});
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
if (statusFilter === "open")
|
||||
if (statusFilter === "open") {
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
!request.isApproved &&
|
||||
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
}
|
||||
if (statusFilter === "close")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
@ -141,11 +144,10 @@ export const AccessApprovalRequest = ({
|
||||
);
|
||||
|
||||
return requests;
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||
return [];
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter, isRequestsLoading]);
|
||||
|
||||
const generateRequestDetails = (request: TAccessApprovalRequest) => {
|
||||
console.log(request);
|
||||
|
||||
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
|
||||
const isRejectedByAnyone = request.reviewers.some(
|
||||
({ status }) => status === ApprovalStatus.REJECTED
|
||||
|
@ -16,7 +16,7 @@ export const RequestAccessModal = ({
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
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} />
|
||||
</ModalContent>
|
||||
|
@ -36,7 +36,7 @@ export const ReviewAccessRequestModal = ({
|
||||
const accessDetails = {
|
||||
env: request.environmentName,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: request.policy.secretPath,
|
||||
secretPaths: request.policy.secretPaths,
|
||||
read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)),
|
||||
edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)),
|
||||
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="mb-1 lowercase">
|
||||
<span className="font-bold capitalize">Requested path: </span>
|
||||
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
|
||||
<span className="font-bold capitalize">Requested environment: </span>
|
||||
<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 className="mb-1">
|
||||
@ -142,8 +150,7 @@ export const ReviewAccessRequestModal = ({
|
||||
<Button
|
||||
isLoading={isLoading === "approved"}
|
||||
isDisabled={
|
||||
!!isLoading ||
|
||||
(!request.isApprover && !byPassApproval && isSoftEnforcement)
|
||||
!!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
|
||||
}
|
||||
onClick={() => handleReview("approved")}
|
||||
className="mt-4"
|
||||
@ -169,9 +176,9 @@ export const ReviewAccessRequestModal = ({
|
||||
isChecked={byPassApproval}
|
||||
id="byPassApproval"
|
||||
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)
|
||||
</span>
|
||||
</Checkbox>
|
||||
|
@ -187,7 +187,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Environment</Th>
|
||||
<Th>Secret Path</Th>
|
||||
<Th>Secret Paths</Th>
|
||||
<Th>Eligible Approvers</Th>
|
||||
<Th>Eligible Group Approvers</Th>
|
||||
<Th>Approval Required</Th>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCheckCircle, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@ -14,6 +14,8 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
@ -22,7 +24,11 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import { useCreateSecretApprovalPolicy, useListWorkspaceGroups, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
|
||||
import {
|
||||
useCreateSecretApprovalPolicy,
|
||||
useListWorkspaceGroups,
|
||||
useUpdateSecretApprovalPolicy
|
||||
} from "@app/hooks/api";
|
||||
import {
|
||||
useCreateAccessApprovalPolicy,
|
||||
useUpdateAccessApprovalPolicy
|
||||
@ -44,9 +50,13 @@ const formSchema = z
|
||||
.object({
|
||||
environment: z.string(),
|
||||
name: z.string().optional(),
|
||||
secretPath: z.string().optional(),
|
||||
secretPaths: z.object({ path: z.string() }).array().default([]),
|
||||
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),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
||||
})
|
||||
@ -70,12 +80,16 @@ export const AccessPolicyForm = ({
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
getValues,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: editValues
|
||||
? {
|
||||
...editValues,
|
||||
secretPaths: (editValues.secretPaths ? editValues.secretPaths : []).map((path) => ({
|
||||
path
|
||||
})),
|
||||
environment: editValues.environment.slug,
|
||||
approvers: editValues?.approvers || [],
|
||||
approvals: editValues?.approvals
|
||||
@ -107,14 +121,23 @@ export const AccessPolicyForm = ({
|
||||
if (data.policyType === PolicyType.ChangePolicy) {
|
||||
await createSecretApprovalPolicy({
|
||||
...data,
|
||||
secretPaths: data.secretPaths.map((path) => path.path),
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
} else if (data.policyType === PolicyType.AccessPolicy) {
|
||||
await createAccessApprovalPolicy({
|
||||
...data,
|
||||
secretPaths: data.secretPaths.map((path) => path.path),
|
||||
projectSlug
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Invalid policy type"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created policy"
|
||||
@ -138,15 +161,24 @@ export const AccessPolicyForm = ({
|
||||
await updateSecretApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
secretPaths: data.secretPaths.map((path) => path.path),
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
} else if (data.policyType === PolicyType.AccessPolicy) {
|
||||
await updateAccessApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
secretPaths: data.secretPaths.map((path) => path.path),
|
||||
projectSlug
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Invalid policy type"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated policy"
|
||||
@ -169,11 +201,10 @@ export const AccessPolicyForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const formatEnforcementLevel = (level: EnforcementLevel) => {
|
||||
if (level === EnforcementLevel.Hard) return "Hard";
|
||||
if (level === EnforcementLevel.Soft) return "Soft";
|
||||
return level;
|
||||
};
|
||||
const secretPathsFields = useFieldArray({
|
||||
control,
|
||||
name: "secretPaths"
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||
@ -252,19 +283,58 @@ export const AccessPolicyForm = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel
|
||||
tooltipText="Select which secret paths in th environment that this policy should apply for."
|
||||
label="Secret Paths"
|
||||
/>
|
||||
</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="secretPath"
|
||||
name={`secretPaths.${i}.path`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
<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
|
||||
control={control}
|
||||
name="approvals"
|
||||
@ -307,12 +377,8 @@ export const AccessPolicyForm = ({
|
||||
>
|
||||
{Object.values(EnforcementLevel).map((level) => {
|
||||
return (
|
||||
<SelectItem
|
||||
value={level}
|
||||
key={`enforcement-level-${level}`}
|
||||
className="text-xs"
|
||||
>
|
||||
{formatEnforcementLevel(level)}
|
||||
<SelectItem value={level} key={`enforcement-level-${level}`}>
|
||||
<span className="capitalize">{level}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
@ -334,7 +400,11 @@ export const AccessPolicyForm = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
@ -347,14 +417,21 @@ export const AccessPolicyForm = ({
|
||||
</DropdownMenuLabel>
|
||||
{members.map(({ 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 (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el: {id: string, type: ApproverType}) => el.id !== userId && el.type !== ApproverType.User)
|
||||
? value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id !== userId && el.type !== ApproverType.User
|
||||
)
|
||||
: [...(value || []), { id: userId, type: ApproverType.User }]
|
||||
);
|
||||
}}
|
||||
@ -384,7 +461,13 @@ export const AccessPolicyForm = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
@ -395,9 +478,14 @@ export const AccessPolicyForm = ({
|
||||
<DropdownMenuLabel>
|
||||
Select groups that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{groups && groups.map(({ group }) => {
|
||||
{groups &&
|
||||
groups.map(({ group }) => {
|
||||
const { id } = group;
|
||||
const isChecked = value?.filter((el: {id: string, type: ApproverType}) => el.id === id && el.type === ApproverType.Group).length > 0;
|
||||
const isChecked =
|
||||
value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id === id && el.type === ApproverType.Group
|
||||
).length > 0;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@ -405,7 +493,10 @@ export const AccessPolicyForm = ({
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el: {id: string, type: ApproverType}) => el.id !== id && el.type !== ApproverType.Group)
|
||||
? value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id !== id && el.type !== ApproverType.Group
|
||||
)
|
||||
: [...(value || []), { id, type: ApproverType.Group }]
|
||||
);
|
||||
}}
|
||||
|
@ -29,13 +29,13 @@ interface IPolicy {
|
||||
name: string;
|
||||
environment: WorkspaceEnv;
|
||||
projectId?: string;
|
||||
secretPath?: string;
|
||||
secretPaths?: string[];
|
||||
approvals: number;
|
||||
approvers?: Approver[];
|
||||
updatedAt: Date;
|
||||
policyType: PolicyType;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
policy: IPolicy;
|
||||
@ -56,10 +56,16 @@ export const ApprovalPolicyRow = ({
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props) => {
|
||||
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
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 [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(
|
||||
policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []
|
||||
);
|
||||
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 { permission } = useProjectPermission();
|
||||
@ -68,7 +74,7 @@ export const ApprovalPolicyRow = ({
|
||||
<Tr>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{policy.environment.slug}</Td>
|
||||
<Td>{policy.secretPath || "*"}</Td>
|
||||
<Td>{policy.secretPaths?.length ? policy?.secretPaths.join(", ") : "Full Environment"}</Td>
|
||||
<Td>
|
||||
<DropdownMenu
|
||||
onOpenChange={(isOpen) => {
|
||||
@ -78,11 +84,15 @@ export const ApprovalPolicyRow = ({
|
||||
{
|
||||
projectSlug,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers)
|
||||
},
|
||||
{
|
||||
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,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers)
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
setSelectedApprovers(
|
||||
policy?.approvers?.filter(
|
||||
(approver) => approver.type === ApproverType.User
|
||||
) || []
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} 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>
|
||||
{members?.map(({ user }) => {
|
||||
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 (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
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}`}
|
||||
@ -156,37 +178,51 @@ export const ApprovalPolicyRow = ({
|
||||
{
|
||||
projectSlug,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers)
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
setSelectedGroupApprovers(
|
||||
policy?.approvers?.filter(
|
||||
(approver) => approver.type === ApproverType.Group
|
||||
) || []
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updateSecretApprovalPolicy(
|
||||
{
|
||||
workspaceId,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers)
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
setSelectedGroupApprovers(
|
||||
policy?.approvers?.filter(
|
||||
(approver) => approver.type === ApproverType.Group
|
||||
) || []
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
setSelectedGroupApprovers(
|
||||
policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"}
|
||||
value={
|
||||
selectedGroupApprovers?.length
|
||||
? `${selectedGroupApprovers.length} selected`
|
||||
: "None"
|
||||
}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
@ -197,16 +233,23 @@ export const ApprovalPolicyRow = ({
|
||||
<DropdownMenuLabel>
|
||||
Select groups that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{groups && groups.map(({ group }) => {
|
||||
{groups &&
|
||||
groups.map(({ group }) => {
|
||||
const { id } = group;
|
||||
const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0;
|
||||
const isChecked =
|
||||
selectedGroupApprovers?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id === id && el.type === ApproverType.Group
|
||||
).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setSelectedGroupApprovers(
|
||||
isChecked
|
||||
? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group)
|
||||
? selectedGroupApprovers?.filter(
|
||||
(el) => el.id !== id || el.type !== ApproverType.Group
|
||||
)
|
||||
: [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }]
|
||||
);
|
||||
}}
|
||||
@ -229,12 +272,12 @@ export const ApprovalPolicyRow = ({
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer">
|
||||
<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">
|
||||
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
|
||||
<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} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="p-1 min-w-[100%]">
|
||||
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
|
Reference in New Issue
Block a user