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(),
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>;

View File

@ -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(),

View File

@ -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()
});

View File

@ -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>;

View File

@ -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>;

View File

@ -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() }),

View File

@ -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()
}),

View File

@ -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: {

View File

@ -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 };
}
});

View File

@ -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 };
};

View File

@ -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 ({

View File

@ -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">;

View File

@ -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,

View File

@ -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)
};
}
}
};

View File

@ -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

View File

@ -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">;

View File

@ -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 || ""
)
);

View File

@ -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: [
{

View File

@ -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;
};

View File

@ -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;

View File

@ -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: {

View File

@ -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`
},

View File

@ -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,

View File

@ -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(),

View File

@ -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(),

View File

@ -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: {

View File

@ -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) => {

View File

@ -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;

View File

@ -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;

View File

@ -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
});

View File

@ -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

View File

@ -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;

View File

@ -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(", ");
};

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }]
);
}}

View File

@ -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}